Commit c47d65e61c7221307e680e5fa13184ac94c9450e
1 parent
a1b3cac4
Exists in
master
and in
29 other branches
ActionItem154: converting svn:externals to piston
git-svn-id: https://svn.colivre.coop.br/svn/noosfero/trunk@1313 3f533792-8f58-4932-b0fe-aaf55b0a4547
Showing
154 changed files
with
9315 additions
and
0 deletions
Show diff stats
Too many changes.
To preserve performance only 100 of 154 files displayed.
... | ... | @@ -0,0 +1,22 @@ |
1 | +require 'rake' | |
2 | +require 'rake/testtask' | |
3 | +require 'rake/rdoctask' | |
4 | + | |
5 | +desc 'Default: run unit tests.' | |
6 | +task :default => :test | |
7 | + | |
8 | +desc 'Test the access_control plugin.' | |
9 | +Rake::TestTask.new(:test) do |t| | |
10 | + t.libs << 'lib' | |
11 | + t.pattern = 'test/**/*_test.rb' | |
12 | + t.verbose = true | |
13 | +end | |
14 | + | |
15 | +desc 'Generate documentation for the access_control plugin.' | |
16 | +Rake::RDocTask.new(:rdoc) do |rdoc| | |
17 | + rdoc.rdoc_dir = 'rdoc' | |
18 | + rdoc.title = 'AccessControl' | |
19 | + rdoc.options << '--line-numbers' << '--inline-source' | |
20 | + rdoc.rdoc_files.include('README') | |
21 | + rdoc.rdoc_files.include('lib/**/*.rb') | |
22 | +end | ... | ... |
vendor/plugins/access_control/generators/access_control_migration/access_control_migration_generator.rb
0 → 100644
vendor/plugins/access_control/generators/access_control_migration/templates/migration.rb
0 → 100644
... | ... | @@ -0,0 +1,22 @@ |
1 | +class AccessControlMigration < ActiveRecord::Migration | |
2 | + def self.up | |
3 | + create_table :roles do |t| | |
4 | + t.column :name, :string | |
5 | + t.column :permissions, :string | |
6 | + end | |
7 | + | |
8 | + create_table :role_assignments do |t| | |
9 | + t.column :accessor_id, :integer | |
10 | + t.column :accessor_type, :string | |
11 | + t.column :resource_id, :integer | |
12 | + t.column :resource_type, :string | |
13 | + t.column :role_id, :integer | |
14 | + t.column :is_global, :boolean | |
15 | + end | |
16 | + end | |
17 | + | |
18 | + def self.down | |
19 | + drop_table :roles | |
20 | + drop_table :role_assignments | |
21 | + end | |
22 | +end | ... | ... |
... | ... | @@ -0,0 +1 @@ |
1 | +# Install hook code here | ... | ... |
... | ... | @@ -0,0 +1,26 @@ |
1 | +class ActiveRecord::Base | |
2 | + # This is the global hash of permissions and each item is of the form | |
3 | + # 'class_name' => permission_hash for each target have its own set of permissions | |
4 | + # but its not a namespace so each permission name should be unique | |
5 | + PERMISSIONS = {} | |
6 | + | |
7 | + # Acts as accessible makes a model acts as a resource that can be targeted by a permission | |
8 | + def self.acts_as_accessible | |
9 | + has_many :role_assignments, :as => :resource | |
10 | + | |
11 | + # A superior instance is an object that has higher level an thus can be targeted by a permission | |
12 | + # to represent an permission over a group of related resources rather than a single one | |
13 | + def superior_instance | |
14 | + nil | |
15 | + end | |
16 | + | |
17 | + def affiliate(accessor, roles) | |
18 | + roles = [roles] unless roles.kind_of?(Array) | |
19 | + roles.map {|role| accessor.add_role(role, self)}.any? | |
20 | + end | |
21 | + | |
22 | + def members | |
23 | + role_assignments.map(&:accessor).uniq | |
24 | + end | |
25 | + end | |
26 | +end | ... | ... |
... | ... | @@ -0,0 +1,58 @@ |
1 | +class ActiveRecord::Base | |
2 | + def self.acts_as_accessor | |
3 | + has_many :role_assignments, :as => :accessor | |
4 | + | |
5 | + def has_permission?(permission, resource = nil) | |
6 | + return true if resource == self | |
7 | + role_assignments.any? {|ra| ra.has_permission?(permission, resource)} | |
8 | + end | |
9 | + | |
10 | + def define_roles(roles, resource) | |
11 | + roles = [roles] unless roles.kind_of?(Array) | |
12 | + actual_roles = RoleAssignment.find( :all, :conditions => role_attributes(nil, resource) ).map(&:role) | |
13 | + | |
14 | + (roles - actual_roles).each {|r| add_role(r, resource) } | |
15 | + (actual_roles - roles).each {|r| remove_role(r, resource)} | |
16 | + end | |
17 | + | |
18 | + def add_role(role, resource) | |
19 | + attributes = role_attributes(role, resource) | |
20 | + if RoleAssignment.find(:all, :conditions => attributes).empty? | |
21 | + RoleAssignment.new(attributes).save | |
22 | + else | |
23 | + false | |
24 | + end | |
25 | + end | |
26 | + | |
27 | + def remove_role(role, resource) | |
28 | + return unless role | |
29 | + roles_destroy = RoleAssignment.find(:all, :conditions => role_attributes(role, resource)) | |
30 | + return if roles_destroy.empty? | |
31 | + roles_destroy.map(&:destroy).all? | |
32 | + end | |
33 | + | |
34 | + def find_roles(res) | |
35 | + RoleAssignment.find(:all, :conditions => role_attributes(nil, res)) | |
36 | + end | |
37 | + | |
38 | + protected | |
39 | + def role_attributes(role, resource) | |
40 | + attributes = {:accessor_id => self.id, :accessor_type => self.class.base_class.name} | |
41 | + if role | |
42 | + attributes[:role_id] = role.id | |
43 | + end | |
44 | + if resource == 'global' | |
45 | + attributes[:is_global] = true | |
46 | + resource = nil | |
47 | + end | |
48 | + if resource | |
49 | + attributes[:resource_id] = resource.id | |
50 | + attributes[:resource_type] = resource.class.base_class.name | |
51 | + else | |
52 | + attributes[:resource_id] = nil | |
53 | + attributes[:resource_type] = nil | |
54 | + end | |
55 | + attributes | |
56 | + end | |
57 | + end | |
58 | +end | ... | ... |
... | ... | @@ -0,0 +1,41 @@ |
1 | +module PermissionCheck | |
2 | + | |
3 | + module ClassMethods | |
4 | + # Declares the +permission+ need to be able to access +action+. | |
5 | + # | |
6 | + # * +permission+ must be a symbol or string naming the needed permission to | |
7 | + # access the specified actions. | |
8 | + # * +target+ is the object over witch the user would need the specified | |
9 | + # permission and must be specified as a symbol or the string 'global'. The controller using | |
10 | + # +target+ must respond to a method with that name returning the object | |
11 | + # against which the permissions needed will be checked or if 'global' is passed it will be | |
12 | + # cheked if the assignment is global | |
13 | + # * +accessor+ is a mehtod that returns the accessor who must have the permission. By default | |
14 | + # is :user | |
15 | + # * +action+ must be a hash of options for a before filter like | |
16 | + # :only => :index or :except => [:edit, :update] by default protects all the actions | |
17 | + def protect(permission, target_method, accessor_method = :user, actions = {}) | |
18 | + actions, accessor_method = accessor_method, :user if accessor_method.kind_of?(Hash) | |
19 | + before_filter actions do |c| | |
20 | + target = target_method.kind_of?(Symbol) ? c.send(target_method) : target_method | |
21 | + accessor = accessor_method.kind_of?(Symbol) ? c.send(accessor_method) : accessor_method | |
22 | + unless accessor && accessor.has_permission?(permission.to_s, target) | |
23 | +# c.instance_variable_set('@b', [accessor, permission, target]) | |
24 | + c.send(:render, :file => access_denied_template_path, :status => 403) && false | |
25 | + end | |
26 | + end | |
27 | + end | |
28 | + | |
29 | + def access_denied_template_path | |
30 | + if File.exists?(File.join(RAILS_ROOT, 'app', 'views','access_control' ,'access_denied.rhtml')) | |
31 | + file_path = File.join(RAILS_ROOT, 'app', 'views','access_control' ,'access_denied.rhtml') | |
32 | + else | |
33 | + file_path = File.join(File.dirname(__FILE__),'..', 'views','access_denied.rhtml') | |
34 | + end | |
35 | + end | |
36 | + end | |
37 | + | |
38 | + def self.included(including) | |
39 | + including.send(:extend, PermissionCheck::ClassMethods) | |
40 | + end | |
41 | +end | ... | ... |
vendor/plugins/access_control/lib/permission_name_helper.rb
0 → 100644
... | ... | @@ -0,0 +1,29 @@ |
1 | +class Role < ActiveRecord::Base | |
2 | + | |
3 | + has_many :role_assignments | |
4 | + serialize :permissions, Array | |
5 | + validates_presence_of :name | |
6 | + validates_uniqueness_of :name | |
7 | + | |
8 | + def initialize(*args) | |
9 | + super(*args) | |
10 | + self[:permissions] ||= [] | |
11 | + end | |
12 | + | |
13 | + def has_permission?(perm) | |
14 | + permissions.include?(perm) | |
15 | + end | |
16 | + | |
17 | + def has_kind?(k) | |
18 | + permissions.any?{|p| perms[k].keys.include?(p)} | |
19 | + end | |
20 | + | |
21 | + def kind | |
22 | + perms.keys.detect{|k| perms[k].keys.include?(permissions[0]) } | |
23 | + end | |
24 | + | |
25 | + protected | |
26 | + def perms | |
27 | + ActiveRecord::Base::PERMISSIONS | |
28 | + end | |
29 | +end | ... | ... |
... | ... | @@ -0,0 +1,18 @@ |
1 | +class RoleAssignment < ActiveRecord::Base | |
2 | + belongs_to :role | |
3 | + belongs_to :accessor, :polymorphic => true | |
4 | + belongs_to :resource, :polymorphic => true | |
5 | + | |
6 | + validates_presence_of :role, :accessor | |
7 | + | |
8 | + def has_permission?(perm, res) | |
9 | + return false unless role.has_permission?(perm.to_s) && (resource || is_global) | |
10 | + return true if is_global | |
11 | + return false if res == 'global' | |
12 | + while res | |
13 | + return true if (resource == res) | |
14 | + res = res.superior_instance | |
15 | + end | |
16 | + return (resource == res) | |
17 | + end | |
18 | +end | ... | ... |
vendor/plugins/access_control/tasks/access_control_tasks.rake
0 → 100644
vendor/plugins/access_control/test/access_control_test.rb
0 → 100644
vendor/plugins/access_control/test/acts_as_accessible_test.rb
0 → 100644
... | ... | @@ -0,0 +1,12 @@ |
1 | +require 'test/unit' | |
2 | +require File.dirname(__FILE__) + '/test_helper' | |
3 | + | |
4 | +class AccessControlTest < Test::Unit::TestCase | |
5 | + def test_can_have_role_in_respect_to_an_resource | |
6 | + r = AccessControlTestResource.create(:name => 'bla') | |
7 | + a = AccessControlTestAccessor.create(:name => 'ze') | |
8 | + member_role = Role.create(:name => 'member', :permissions => ['bli']) | |
9 | + r.affiliate(a, member_role) | |
10 | + assert a.has_permission?('bli', r) | |
11 | + end | |
12 | +end | ... | ... |
vendor/plugins/access_control/test/acts_as_accessor_test.rb
0 → 100644
... | ... | @@ -0,0 +1,58 @@ |
1 | +require 'test/unit' | |
2 | +require File.dirname(__FILE__) + '/test_helper' | |
3 | + | |
4 | +class ActAsAccessorTest < Test::Unit::TestCase | |
5 | + def test_can_have_role_in_respect_to_an_resource | |
6 | + res = AccessControlTestResource.create!(:name => 'bla') | |
7 | + a = AccessControlTestAccessor.create!(:name => 'ze') | |
8 | + role = Role.create!(:name => 'just_a_member', :permissions => ['bli']) | |
9 | + assert a.add_role(role, res) | |
10 | + assert a.has_permission?('bli', res) | |
11 | + end | |
12 | + | |
13 | + def test_can_have_a_global_role | |
14 | + r = AccessControlTestResource.create!(:name => 'bla') | |
15 | + a = AccessControlTestAccessor.create!(:name => 'ze') | |
16 | + member_role = Role.create!(:name => 'just_a_moderator', :permissions => ['bli']) | |
17 | + assert a.add_role(member_role, 'global') | |
18 | + assert a.has_permission?('bli', 'global') | |
19 | + end | |
20 | + | |
21 | + def test_add_role | |
22 | + res = AccessControlTestResource.create!(:name => 'bla') | |
23 | + a = AccessControlTestAccessor.create!(:name => 'ze') | |
24 | + role = Role.create!(:name => 'just_a_content_author', :permissions => ['bli']) | |
25 | + assert a.add_role(role, res) | |
26 | + assert a.role_assignments.map{|ra|[ra.role, ra.accessor, ra.resource]}.include?([role, a, res]) | |
27 | + end | |
28 | + | |
29 | + def test_remove_role | |
30 | + res = AccessControlTestResource.create!(:name => 'bla') | |
31 | + a = AccessControlTestAccessor.create!(:name => 'ze') | |
32 | + role = Role.create!(:name => 'just_an_author', :permissions => ['bli']) | |
33 | + ra = RoleAssignment.create!(:accessor => a, :role => role, :resource => res) | |
34 | + | |
35 | + assert a.role_assignments.include?(ra) | |
36 | + assert a.remove_role(role, res) | |
37 | + a.reload | |
38 | + assert !a.role_assignments.map{|ra|[ra.role, ra.accessor, ra.resource]}.include?([role, a, res]) | |
39 | + end | |
40 | + | |
41 | + def test_do_not_add_role_twice | |
42 | + res = AccessControlTestResource.create!(:name => 'bla') | |
43 | + a = AccessControlTestAccessor.create!(:name => 'ze') | |
44 | + role = Role.create!(:name => 'a_content_author', :permissions => ['bli']) | |
45 | + assert a.add_role(role, res) | |
46 | + assert !a.add_role(role, res) | |
47 | + assert a.role_assignments.map{|ra|[ra.role, ra.accessor, ra.resource]}.include?([role, a, res]) | |
48 | + end | |
49 | + | |
50 | + def test_do_not_remove_inexistent_role | |
51 | + res = AccessControlTestResource.create!(:name => 'bla') | |
52 | + a = AccessControlTestAccessor.create!(:name => 'ze') | |
53 | + role = Role.create!(:name => 'an_author', :permissions => ['bli']) | |
54 | + | |
55 | + assert !a.role_assignments.map{|ra|[ra.role, ra.accessor, ra.resource]}.include?([role, a, res]) | |
56 | + assert !a.remove_role(role, res) | |
57 | + end | |
58 | +end | ... | ... |
vendor/plugins/access_control/test/permission_check_test.rb
0 → 100644
... | ... | @@ -0,0 +1,41 @@ |
1 | +require File.join(File.dirname(__FILE__), 'test_helper') | |
2 | + | |
3 | +class AccessControlTestController; def rescue_action(e) raise e end; end | |
4 | +class PermissionCheckTest < Test::Unit::TestCase | |
5 | + | |
6 | + def setup | |
7 | + @controller = AccessControlTestController.new | |
8 | + @request = ActionController::TestRequest.new | |
9 | + @response = ActionController::TestResponse.new | |
10 | + end | |
11 | + | |
12 | + def test_access_denied | |
13 | + get :index | |
14 | + assert_response 403 | |
15 | + assert_template 'access_denied.rhtml' | |
16 | + end | |
17 | + | |
18 | + def test_global_permission_granted | |
19 | + user = AccessControlTestAccessor.create!(:name => 'user') | |
20 | + role = Role.create!(:name => 'some_role', :permissions => ['see_index']) | |
21 | + assert user.add_role(role, 'global') | |
22 | + assert user.has_permission?('see_index', 'global') | |
23 | + | |
24 | + get :index, :user => user.id | |
25 | + assert_response :success | |
26 | + assert_template nil | |
27 | + end | |
28 | + | |
29 | + def test_specific_permission_granted | |
30 | + user = AccessControlTestAccessor.create!(:name => 'other_user') | |
31 | + role = Role.create!(:name => 'other_role', :permissions => ['do_some_stuff']) | |
32 | + resource = AccessControlTestResource.create!(:name => 'some_resource') | |
33 | + assert user.add_role(role, resource) | |
34 | + assert user.has_permission?('do_some_stuff', resource) | |
35 | + | |
36 | + get :other_stuff, :user => user.id, :resource => resource.id | |
37 | + assert_response :success | |
38 | + assert_template nil | |
39 | + | |
40 | + end | |
41 | +end | ... | ... |
vendor/plugins/access_control/test/role_assignment_test.rb
0 → 100644
... | ... | @@ -0,0 +1,32 @@ |
1 | +require File.dirname(__FILE__) + '/test_helper' | |
2 | + | |
3 | +class RoleAssignmentTest < Test::Unit::TestCase | |
4 | + | |
5 | + def test_has_global_permission | |
6 | + role = Role.create(:name => 'new_role', :permissions => ['permission']) | |
7 | + ra = RoleAssignment.create(:role => role, :is_global => true) | |
8 | + assert ra.has_permission?('permission', 'global') | |
9 | + assert !ra.has_permission?('not_permitted', 'global') | |
10 | + end | |
11 | + | |
12 | + def test_has_global_permission_with_global_resource | |
13 | + role = Role.create(:name => 'new_role', :permissions => ['permission']) | |
14 | + ra = RoleAssignment.create(:role => role, :is_global => true) | |
15 | + assert ra.has_permission?('permission', 'global') | |
16 | + assert !ra.has_permission?('not_permitted', 'global') | |
17 | + end | |
18 | + | |
19 | + def test_has_specific_permission | |
20 | + role = Role.create(:name => 'new_role', :permissions => ['permission']) | |
21 | + accessor = AccessControlTestAccessor.create(:name => 'accessor') | |
22 | + resource_A = AccessControlTestResource.create(:name => 'Resource A') | |
23 | + resource_B = AccessControlTestResource.create(:name => 'Resource B') | |
24 | + ra = RoleAssignment.create(:accessor => accessor, :role => role, :resource => resource_A) | |
25 | + assert !ra.new_record? | |
26 | + assert_equal role, ra.role | |
27 | + assert_equal accessor, ra.accessor | |
28 | + assert_equal resource_A, ra.resource | |
29 | + assert ra.has_permission?('permission', resource_A) | |
30 | + assert !ra.has_permission?('permission', resource_B) | |
31 | + end | |
32 | +end | ... | ... |
... | ... | @@ -0,0 +1,28 @@ |
1 | +require File.join(File.dirname(__FILE__), 'test_helper') | |
2 | + | |
3 | + | |
4 | +class RoleTest < Test::Unit::TestCase | |
5 | + | |
6 | + def test_role_creation | |
7 | + count = Role.count | |
8 | + role = Role.new(:name => 'any_role') | |
9 | + assert role.save | |
10 | + assert_equal count + 1, Role.count | |
11 | + end | |
12 | + | |
13 | + def test_uniqueness_of_name | |
14 | + Role.create(:name => 'role_name') | |
15 | + role = Role.new(:name => 'role_name') | |
16 | + assert ! role.save | |
17 | + end | |
18 | + | |
19 | + def test_permission_setting | |
20 | + role = Role.new(:name => 'permissive_role', :permissions => ['edit_profile']) | |
21 | + assert role.save | |
22 | + assert role.has_permission?('edit_profile') | |
23 | + role.permissions << 'post_content' | |
24 | + assert role.save | |
25 | + assert role.has_permission?('post_content') | |
26 | + assert role.has_permission?('edit_profile') | |
27 | + end | |
28 | +end | ... | ... |
... | ... | @@ -0,0 +1,31 @@ |
1 | +ActiveRecord::Migration.verbose = false | |
2 | + | |
3 | +ActiveRecord::Schema.define(:version => 0) do | |
4 | + | |
5 | + create_table :access_control_test_roles, :force => true do |t| | |
6 | + t.column :name, :string | |
7 | + t.column :permissions, :string | |
8 | + end | |
9 | + | |
10 | + create_table :access_control_test_role_assignments, :force => true do |t| | |
11 | + t.column :role_id, :integer | |
12 | + t.column :accessor_id, :integer | |
13 | + t.column :accessor_type, :string | |
14 | + t.column :resource_id, :integer | |
15 | + t.column :resource_type, :string | |
16 | + t.column :is_global, :boolean | |
17 | + end | |
18 | + | |
19 | + create_table :access_control_test_accessors, :force => true do |t| | |
20 | + t.column :name, :string | |
21 | + end | |
22 | + | |
23 | + create_table :access_control_test_resources, :force => true do |t| | |
24 | + t.column :name, :string | |
25 | + end | |
26 | +end | |
27 | + | |
28 | +ActiveRecord::Migration.verbose = true | |
29 | + | |
30 | + | |
31 | + | ... | ... |
... | ... | @@ -0,0 +1,51 @@ |
1 | +ENV["RAILS_ENV"] = "test" | |
2 | +require File.expand_path(File.dirname(__FILE__) + "/../../../../config/environment") | |
3 | + | |
4 | +require 'test/unit' | |
5 | +require 'mocha' | |
6 | + | |
7 | +# from Rails | |
8 | +require 'test_help' | |
9 | + | |
10 | +# load the database schema for the tests | |
11 | +ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log") | |
12 | +load(File.dirname(__FILE__) + '/schema.rb') | |
13 | +# change the table names for the tests to not touch | |
14 | +Role.set_table_name 'access_control_test_roles' | |
15 | +RoleAssignment.set_table_name 'access_control_test_role_assignments' | |
16 | + | |
17 | +# accessor example class to access some resources | |
18 | +class AccessControlTestAccessor < ActiveRecord::Base | |
19 | + set_table_name 'access_control_test_accessors' | |
20 | + acts_as_accessor | |
21 | +end | |
22 | + | |
23 | +# resource example class to be accessed by some accessor | |
24 | +class AccessControlTestResource < ActiveRecord::Base | |
25 | + set_table_name 'access_control_test_resources' | |
26 | + acts_as_accessible | |
27 | + PERMISSIONS[self.class.name] = {'bla' => N_('Bla')} | |
28 | +end | |
29 | + | |
30 | +# controller to test protection | |
31 | +class AccessControlTestController < ApplicationController | |
32 | + include PermissionCheck | |
33 | + protect 'see_index', 'global', :user, :only => :index | |
34 | + protect 'do_some_stuff', :resource, :user, :only => :other_stuff | |
35 | + def index | |
36 | + render :text => 'test controller' | |
37 | + end | |
38 | + | |
39 | + def other_stuff | |
40 | + render :text => 'test stuff' | |
41 | + end | |
42 | + | |
43 | +protected | |
44 | + def user | |
45 | + AccessControlTestAccessor.find(params[:user]) if params[:user] | |
46 | + end | |
47 | + | |
48 | + def resource | |
49 | + AccessControlTestResource.find(params[:resource]) if params[:resource] | |
50 | + end | |
51 | +end | ... | ... |
... | ... | @@ -0,0 +1 @@ |
1 | +# Uninstall hook code here | ... | ... |
... | ... | @@ -0,0 +1,20 @@ |
1 | +Copyright (c) 2006 Kasper Weibel, Jens Kraemer | |
2 | + | |
3 | +Permission is hereby granted, free of charge, to any person obtaining | |
4 | +a copy of this software and associated documentation files (the | |
5 | +"Software"), to deal in the Software without restriction, including | |
6 | +without limitation the rights to use, copy, modify, merge, publish, | |
7 | +distribute, sublicense, and/or sell copies of the Software, and to | |
8 | +permit persons to whom the Software is furnished to do so, subject to | |
9 | +the following conditions: | |
10 | + | |
11 | +The above copyright notice and this permission notice shall be | |
12 | +included in all copies or substantial portions of the Software. | |
13 | + | |
14 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
15 | +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
16 | +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |
17 | +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | |
18 | +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | |
19 | +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | |
20 | +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | ... | ... |
... | ... | @@ -0,0 +1,49 @@ |
1 | += acts_as_ferret | |
2 | + | |
3 | +This ActiveRecord mixin adds full text search capabilities to any Rails model. | |
4 | + | |
5 | +It is heavily based on the original acts_as_ferret plugin done by | |
6 | +Kasper Weibel and a modified version done by Thomas Lockney, which | |
7 | +both can be found on http://ferret.davebalmain.com/trac/wiki/FerretOnRails | |
8 | + | |
9 | +== Installation | |
10 | + | |
11 | +=== Installation inside your Rails project via script/plugin | |
12 | + | |
13 | +script/plugin install svn://projects.jkraemer.net/acts_as_ferret/trunk/plugin/acts_as_ferret | |
14 | + | |
15 | + | |
16 | +=== System-wide installation with Rubygems | |
17 | + | |
18 | +<tt>sudo gem install acts_as_ferret</tt> | |
19 | + | |
20 | +To use acts_as_ferret in your project, add the following line to your | |
21 | +project's config/environment.rb: | |
22 | + | |
23 | +<tt>require 'acts_as_ferret'</tt> | |
24 | + | |
25 | + | |
26 | +== Usage | |
27 | + | |
28 | +include the following in your model class (specifiying the fields you want to get indexed): | |
29 | + | |
30 | +<tt>acts_as_ferret :fields => [ :title, :description ]</tt> | |
31 | + | |
32 | +now you can use ModelClass.find_by_contents(query) to find instances of your model | |
33 | +whose indexed fields match a given query. All query terms are required by default, | |
34 | +but explicit OR queries are possible. This differs from the ferret default, but | |
35 | +imho is the more often needed/expected behaviour (more query terms result in | |
36 | +less results). | |
37 | + | |
38 | +Please see ActsAsFerret::ActMethods#acts_as_ferret for more information. | |
39 | + | |
40 | +== License | |
41 | + | |
42 | +Released under the MIT license. | |
43 | + | |
44 | +== Authors | |
45 | + | |
46 | +* Kasper Weibel Nielsen-Refs (original author) | |
47 | +* Jens Kraemer <jk@jkraemer.net> (current maintainer) | |
48 | + | |
49 | + | ... | ... |
... | ... | @@ -0,0 +1,22 @@ |
1 | +# Copyright (c) 2006 Kasper Weibel Nielsen-Refs, Thomas Lockney, Jens Krämer | |
2 | +# | |
3 | +# Permission is hereby granted, free of charge, to any person obtaining a copy | |
4 | +# of this software and associated documentation files (the "Software"), to deal | |
5 | +# in the Software without restriction, including without limitation the rights | |
6 | +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
7 | +# copies of the Software, and to permit persons to whom the Software is | |
8 | +# furnished to do so, subject to the following conditions: | |
9 | +# | |
10 | +# The above copyright notice and this permission notice shall be included in all | |
11 | +# copies or substantial portions of the Software. | |
12 | +# | |
13 | +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
14 | +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
15 | +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
16 | +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
17 | +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
18 | +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
19 | +# SOFTWARE. | |
20 | + | |
21 | +require 'acts_as_ferret' | |
22 | + | ... | ... |
... | ... | @@ -0,0 +1,19 @@ |
1 | +# acts_as_ferret install script | |
2 | +require 'fileutils' | |
3 | + | |
4 | +def install(file) | |
5 | + puts "Installing: #{file}" | |
6 | + target = File.join(File.dirname(__FILE__), '..', '..', '..', file) | |
7 | + if File.exists?(target) | |
8 | + puts "target #{target} already exists, skipping" | |
9 | + else | |
10 | + FileUtils.cp File.join(File.dirname(__FILE__), file), target | |
11 | + end | |
12 | +end | |
13 | + | |
14 | +install File.join( 'script', 'ferret_start' ) | |
15 | +install File.join( 'script', 'ferret_stop' ) | |
16 | +install File.join( 'config', 'ferret_server.yml' ) | |
17 | + | |
18 | +puts IO.read(File.join(File.dirname(__FILE__), 'README')) | |
19 | + | ... | ... |
... | ... | @@ -0,0 +1,242 @@ |
1 | +module ActsAsFerret #:nodoc: | |
2 | + | |
3 | + # This module defines the acts_as_ferret method and is included into | |
4 | + # ActiveRecord::Base | |
5 | + module ActMethods | |
6 | + | |
7 | + | |
8 | + def reloadable?; false end | |
9 | + | |
10 | + # declares a class as ferret-searchable. | |
11 | + # | |
12 | + # ====options: | |
13 | + # fields:: names all fields to include in the index. If not given, | |
14 | + # all attributes of the class will be indexed. You may also give | |
15 | + # symbols pointing to instance methods of your model here, i.e. | |
16 | + # to retrieve and index data from a related model. | |
17 | + # | |
18 | + # additional_fields:: names fields to include in the index, in addition | |
19 | + # to those derived from the db scheme. use if you want | |
20 | + # to add custom fields derived from methods to the db | |
21 | + # fields (which will be picked by aaf). This option will | |
22 | + # be ignored when the fields option is given, in that | |
23 | + # case additional fields get specified there. | |
24 | + # | |
25 | + # index_dir:: declares the directory where to put the index for this class. | |
26 | + # The default is RAILS_ROOT/index/RAILS_ENV/CLASSNAME. | |
27 | + # The index directory will be created if it doesn't exist. | |
28 | + # | |
29 | + # single_index:: set this to true to let this class use a Ferret | |
30 | + # index that is shared by all classes having :single_index set to true. | |
31 | + # :store_class_name is set to true implicitly, as well as index_dir, so | |
32 | + # don't bother setting these when using this option. the shared index | |
33 | + # will be located in index/<RAILS_ENV>/shared . | |
34 | + # | |
35 | + # store_class_name:: to make search across multiple models (with either | |
36 | + # single_index or the multi_search method) useful, set | |
37 | + # this to true. the model class name will be stored in a keyword field | |
38 | + # named class_name | |
39 | + # | |
40 | + # reindex_batch_size:: reindexing is done in batches of this size, default is 1000 | |
41 | + # | |
42 | + # ferret:: Hash of Options that directly influence the way the Ferret engine works. You | |
43 | + # can use most of the options the Ferret::I class accepts here, too. Among the | |
44 | + # more useful are: | |
45 | + # | |
46 | + # or_default:: whether query terms are required by | |
47 | + # default (the default, false), or not (true) | |
48 | + # | |
49 | + # analyzer:: the analyzer to use for query parsing (default: nil, | |
50 | + # which means the ferret StandardAnalyzer gets used) | |
51 | + # | |
52 | + # default_field:: use to set one or more fields that are searched for query terms | |
53 | + # that don't have an explicit field list. This list should *not* | |
54 | + # contain any untokenized fields. If it does, you're asking | |
55 | + # for trouble (i.e. not getting results for queries having | |
56 | + # stop words in them). Aaf by default initializes the default field | |
57 | + # list to contain all tokenized fields. If you use :single_index => true, | |
58 | + # you really should set this option specifying your default field | |
59 | + # list (which should be equal in all your classes sharing the index). | |
60 | + # Otherwise you might get incorrect search results and you won't get | |
61 | + # any lazy loading of stored field data. | |
62 | + # | |
63 | + # For downwards compatibility reasons you can also specify the Ferret options in the | |
64 | + # last Hash argument. | |
65 | + def acts_as_ferret(options={}, ferret_options={}) | |
66 | + | |
67 | + # force local mode if running *inside* the Ferret server - somewhere the | |
68 | + # real indexing has to be done after all :-) | |
69 | + # Usually the automatic detection of server mode works fine, however if you | |
70 | + # require your model classes in environment.rb they will get loaded before the | |
71 | + # DRb server is started, so this code is executed too early and detection won't | |
72 | + # work. In this case you'll get endless loops resulting in "stack level too deep" | |
73 | + # errors. | |
74 | + # To get around this, start the server with the environment variable | |
75 | + # FERRET_USE_LOCAL_INDEX set to '1'. | |
76 | + logger.debug "Asked for a remote server ? #{options[:remote].inspect}, ENV[\"FERRET_USE_LOCAL_INDEX\"] is #{ENV["FERRET_USE_LOCAL_INDEX"].inspect}, looks like we are#{ActsAsFerret::Remote::Server.running || ENV['FERRET_USE_LOCAL_INDEX'] ? '' : ' not'} the server" | |
77 | + options.delete(:remote) if ENV["FERRET_USE_LOCAL_INDEX"] || ActsAsFerret::Remote::Server.running | |
78 | + | |
79 | + if options[:remote] && options[:remote] !~ /^druby/ | |
80 | + # read server location from config/ferret_server.yml | |
81 | + options[:remote] = ActsAsFerret::Remote::Config.load("#{RAILS_ROOT}/config/ferret_server.yml")[:uri] rescue nil | |
82 | + end | |
83 | + | |
84 | + if options[:remote] | |
85 | + logger.debug "Will use remote index server which should be available at #{options[:remote]}" | |
86 | + else | |
87 | + logger.debug "Will use local index." | |
88 | + end | |
89 | + | |
90 | + | |
91 | + extend ClassMethods | |
92 | + extend SharedIndexClassMethods if options[:single_index] | |
93 | + | |
94 | + include InstanceMethods | |
95 | + include MoreLikeThis::InstanceMethods | |
96 | + | |
97 | + # AR hooks | |
98 | + after_create :ferret_create | |
99 | + after_update :ferret_update | |
100 | + after_destroy :ferret_destroy | |
101 | + | |
102 | + cattr_accessor :aaf_configuration | |
103 | + | |
104 | + # default config | |
105 | + self.aaf_configuration = { | |
106 | + :index_dir => "#{ActsAsFerret::index_dir}/#{self.name.underscore}", | |
107 | + :store_class_name => false, | |
108 | + :name => self.table_name, | |
109 | + :class_name => self.name, | |
110 | + :single_index => false, | |
111 | + :reindex_batch_size => 1000, | |
112 | + :ferret => {}, # Ferret config Hash | |
113 | + :ferret_fields => {} # list of indexed fields that will be filled later | |
114 | + } | |
115 | + | |
116 | + # merge aaf options with args | |
117 | + aaf_configuration.update(options) if options.is_a?(Hash) | |
118 | + # apply appropriate settings for shared index | |
119 | + if aaf_configuration[:single_index] | |
120 | + aaf_configuration[:index_dir] = "#{ActsAsFerret::index_dir}/shared" | |
121 | + aaf_configuration[:store_class_name] = true | |
122 | + end | |
123 | + | |
124 | + # set ferret default options | |
125 | + aaf_configuration[:ferret].reverse_merge!( :or_default => false, | |
126 | + :handle_parse_errors => true, | |
127 | + :default_field => nil # will be set later on | |
128 | + #:max_clauses => 512, | |
129 | + #:analyzer => Ferret::Analysis::StandardAnalyzer.new, | |
130 | + # :wild_card_downcase => true | |
131 | + ) | |
132 | + | |
133 | + # merge ferret options with those from second parameter hash | |
134 | + aaf_configuration[:ferret].update(ferret_options) if ferret_options.is_a?(Hash) | |
135 | + | |
136 | + unless options[:remote] | |
137 | + ActsAsFerret::ensure_directory aaf_configuration[:index_dir] | |
138 | + aaf_configuration[:index_base_dir] = aaf_configuration[:index_dir] | |
139 | + aaf_configuration[:index_dir] = find_last_index_version(aaf_configuration[:index_dir]) | |
140 | + logger.debug "using index in #{aaf_configuration[:index_dir]}" | |
141 | + end | |
142 | + | |
143 | + # these properties are somewhat vital to the plugin and shouldn't | |
144 | + # be overwritten by the user: | |
145 | + aaf_configuration[:ferret].update( | |
146 | + :key => (aaf_configuration[:single_index] ? [:id, :class_name] : :id), | |
147 | + :path => aaf_configuration[:index_dir], | |
148 | + :auto_flush => true, # slower but more secure in terms of locking problems TODO disable when running in drb mode? | |
149 | + :create_if_missing => true | |
150 | + ) | |
151 | + | |
152 | + if aaf_configuration[:fields] | |
153 | + add_fields(aaf_configuration[:fields]) | |
154 | + else | |
155 | + add_fields(self.new.attributes.keys.map { |k| k.to_sym }) | |
156 | + add_fields(aaf_configuration[:additional_fields]) | |
157 | + end | |
158 | + | |
159 | + # now that all fields have been added, we can initialize the default | |
160 | + # field list to be used by the query parser. | |
161 | + # It will include all content fields *not* marked as :untokenized. | |
162 | + # This fixes the otherwise failing CommentTest#test_stopwords. Basically | |
163 | + # this means that by default only tokenized fields (which is the default) | |
164 | + # will be searched. If you want to search inside the contents of an | |
165 | + # untokenized field, you'll have to explicitly specify it in your query. | |
166 | + # | |
167 | + # Unfortunately this is not very useful with a shared index (see | |
168 | + # http://projects.jkraemer.net/acts_as_ferret/ticket/85) | |
169 | + # You should consider specifying the default field list to search for as | |
170 | + # part of the ferret_options hash in your call to acts_as_ferret. | |
171 | + aaf_configuration[:ferret][:default_field] ||= if aaf_configuration[:single_index] | |
172 | + logger.warn "You really should set the acts_as_ferret :default_field option when using a shared index!" | |
173 | + '*' | |
174 | + else | |
175 | + aaf_configuration[:ferret_fields].keys.select do |f| | |
176 | + aaf_configuration[:ferret_fields][f][:index] != :untokenized | |
177 | + end | |
178 | + end | |
179 | + logger.info "default field list: #{aaf_configuration[:ferret][:default_field].inspect}" | |
180 | + | |
181 | + if options[:remote] | |
182 | + aaf_index.ensure_index_exists | |
183 | + end | |
184 | + end | |
185 | + | |
186 | + | |
187 | + protected | |
188 | + | |
189 | + # find the most recent version of an index | |
190 | + def find_last_index_version(basedir) | |
191 | + # check for versioned index | |
192 | + versions = Dir.entries(basedir).select do |f| | |
193 | + dir = File.join(basedir, f) | |
194 | + File.directory?(dir) && File.file?(File.join(dir, 'segments')) && f =~ /^\d+(_\d+)?$/ | |
195 | + end | |
196 | + if versions.any? | |
197 | + # select latest version | |
198 | + versions.sort! | |
199 | + File.join basedir, versions.last | |
200 | + else | |
201 | + basedir | |
202 | + end | |
203 | + end | |
204 | + | |
205 | + | |
206 | + # helper that defines a method that adds the given field to a ferret | |
207 | + # document instance | |
208 | + def define_to_field_method(field, options = {}) | |
209 | + options.reverse_merge!( :store => :no, | |
210 | + :highlight => :yes, | |
211 | + :index => :yes, | |
212 | + :term_vector => :with_positions_offsets, | |
213 | + :boost => 1.0 ) | |
214 | + options[:term_vector] = :no if options[:index] == :no | |
215 | + aaf_configuration[:ferret_fields][field] = options | |
216 | + define_method("#{field}_to_ferret".to_sym) do | |
217 | + begin | |
218 | + val = content_for_field_name(field) | |
219 | + rescue | |
220 | + logger.warn("Error retrieving value for field #{field}: #{$!}") | |
221 | + val = '' | |
222 | + end | |
223 | + logger.debug("Adding field #{field} with value '#{val}' to index") | |
224 | + val | |
225 | + end | |
226 | + end | |
227 | + | |
228 | + def add_fields(field_config) | |
229 | + if field_config.is_a? Hash | |
230 | + field_config.each_pair do |key,val| | |
231 | + define_to_field_method(key,val) | |
232 | + end | |
233 | + elsif field_config.respond_to?(:each) | |
234 | + field_config.each do |field| | |
235 | + define_to_field_method(field) | |
236 | + end | |
237 | + end | |
238 | + end | |
239 | + | |
240 | + end | |
241 | + | |
242 | +end | ... | ... |
... | ... | @@ -0,0 +1,160 @@ |
1 | +# Copyright (c) 2006 Kasper Weibel Nielsen-Refs, Thomas Lockney, Jens Krämer | |
2 | +# | |
3 | +# Permission is hereby granted, free of charge, to any person obtaining a copy | |
4 | +# of this software and associated documentation files (the "Software"), to deal | |
5 | +# in the Software without restriction, including without limitation the rights | |
6 | +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
7 | +# copies of the Software, and to permit persons to whom the Software is | |
8 | +# furnished to do so, subject to the following conditions: | |
9 | +# | |
10 | +# The above copyright notice and this permission notice shall be included in all | |
11 | +# copies or substantial portions of the Software. | |
12 | +# | |
13 | +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
14 | +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
15 | +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
16 | +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
17 | +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
18 | +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
19 | +# SOFTWARE. | |
20 | + | |
21 | +require 'active_support' | |
22 | +require 'active_record' | |
23 | +require 'set' | |
24 | +require 'ferret' | |
25 | + | |
26 | +require 'ferret_extensions' | |
27 | +require 'act_methods' | |
28 | +require 'class_methods' | |
29 | +require 'shared_index_class_methods' | |
30 | +require 'ferret_result' | |
31 | +require 'instance_methods' | |
32 | + | |
33 | +require 'multi_index' | |
34 | +require 'more_like_this' | |
35 | + | |
36 | +require 'index' | |
37 | +require 'local_index' | |
38 | +require 'shared_index' | |
39 | +require 'remote_index' | |
40 | + | |
41 | +require 'ferret_server' | |
42 | + | |
43 | + | |
44 | +# The Rails ActiveRecord Ferret Mixin. | |
45 | +# | |
46 | +# This mixin adds full text search capabilities to any Rails model. | |
47 | +# | |
48 | +# The current version emerged from on the original acts_as_ferret plugin done by | |
49 | +# Kasper Weibel and a modified version done by Thomas Lockney, which both can be | |
50 | +# found on the Ferret Wiki: http://ferret.davebalmain.com/trac/wiki/FerretOnRails. | |
51 | +# | |
52 | +# basic usage: | |
53 | +# include the following in your model class (specifiying the fields you want to get indexed): | |
54 | +# acts_as_ferret :fields => [ :title, :description ] | |
55 | +# | |
56 | +# now you can use ModelClass.find_by_contents(query) to find instances of your model | |
57 | +# whose indexed fields match a given query. All query terms are required by default, but | |
58 | +# explicit OR queries are possible. This differs from the ferret default, but imho is the more | |
59 | +# often needed/expected behaviour (more query terms result in less results). | |
60 | +# | |
61 | +# Released under the MIT license. | |
62 | +# | |
63 | +# Authors: | |
64 | +# Kasper Weibel Nielsen-Refs (original author) | |
65 | +# Jens Kraemer <jk@jkraemer.net> (active maintainer) | |
66 | +# | |
67 | +module ActsAsFerret | |
68 | + | |
69 | + # global Hash containing all multi indexes created by all classes using the plugin | |
70 | + # key is the concatenation of alphabetically sorted names of the classes the | |
71 | + # searcher searches. | |
72 | + @@multi_indexes = Hash.new | |
73 | + def self.multi_indexes; @@multi_indexes end | |
74 | + | |
75 | + # global Hash containing the ferret indexes of all classes using the plugin | |
76 | + # key is the index directory. | |
77 | + @@ferret_indexes = Hash.new | |
78 | + def self.ferret_indexes; @@ferret_indexes end | |
79 | + | |
80 | + | |
81 | + # decorator that adds a total_hits accessor to search result arrays | |
82 | + class SearchResults | |
83 | + attr_reader :total_hits | |
84 | + def initialize(results, total_hits) | |
85 | + @results = results | |
86 | + @total_hits = total_hits | |
87 | + end | |
88 | + def method_missing(symbol, *args, &block) | |
89 | + @results.send(symbol, *args, &block) | |
90 | + end | |
91 | + def respond_to?(name) | |
92 | + self.methods.include?(name) || @results.respond_to?(name) | |
93 | + end | |
94 | + end | |
95 | + | |
96 | + def self.ensure_directory(dir) | |
97 | + FileUtils.mkdir_p dir unless (File.directory?(dir) || File.symlink?(dir)) | |
98 | + end | |
99 | + | |
100 | + # make sure the default index base dir exists. by default, all indexes are created | |
101 | + # under RAILS_ROOT/index/RAILS_ENV | |
102 | + def self.init_index_basedir | |
103 | + index_base = "#{RAILS_ROOT}/index" | |
104 | + @@index_dir = "#{index_base}/#{RAILS_ENV}" | |
105 | + end | |
106 | + | |
107 | + mattr_accessor :index_dir | |
108 | + init_index_basedir | |
109 | + | |
110 | + def self.append_features(base) | |
111 | + super | |
112 | + base.extend(ClassMethods) | |
113 | + end | |
114 | + | |
115 | + # builds a FieldInfos instance for creation of an index containing fields | |
116 | + # for the given model classes. | |
117 | + def self.field_infos(models) | |
118 | + # default attributes for fields | |
119 | + fi = Ferret::Index::FieldInfos.new(:store => :no, | |
120 | + :index => :yes, | |
121 | + :term_vector => :no, | |
122 | + :boost => 1.0) | |
123 | + # primary key | |
124 | + fi.add_field(:id, :store => :yes, :index => :untokenized) | |
125 | + fields = {} | |
126 | + have_class_name = false | |
127 | + models.each do |model| | |
128 | + fields.update(model.aaf_configuration[:ferret_fields]) | |
129 | + # class_name | |
130 | + if !have_class_name && model.aaf_configuration[:store_class_name] | |
131 | + fi.add_field(:class_name, :store => :yes, :index => :untokenized) | |
132 | + have_class_name = true | |
133 | + end | |
134 | + end | |
135 | + fields.each_pair do |field, options| | |
136 | + fi.add_field(field, { :store => :no, | |
137 | + :index => :yes }.update(options)) | |
138 | + end | |
139 | + return fi | |
140 | + end | |
141 | + | |
142 | + def self.close_multi_indexes | |
143 | + # close combined index readers, just in case | |
144 | + # this seems to fix a strange test failure that seems to relate to a | |
145 | + # multi_index looking at an old version of the content_base index. | |
146 | + multi_indexes.each_pair do |key, index| | |
147 | + # puts "#{key} -- #{self.name}" | |
148 | + # TODO only close those where necessary (watch inheritance, where | |
149 | + # self.name is base class of a class where key is made from) | |
150 | + index.close #if key =~ /#{self.name}/ | |
151 | + end | |
152 | + multi_indexes.clear | |
153 | + end | |
154 | + | |
155 | +end | |
156 | + | |
157 | +# include acts_as_ferret method into ActiveRecord::Base | |
158 | +ActiveRecord::Base.extend ActsAsFerret::ActMethods | |
159 | + | |
160 | + | ... | ... |
... | ... | @@ -0,0 +1,316 @@ |
1 | +module ActsAsFerret | |
2 | + | |
3 | + module ClassMethods | |
4 | + | |
5 | + # rebuild the index from all data stored for this model. | |
6 | + # This is called automatically when no index exists yet. | |
7 | + # | |
8 | + # When calling this method manually, you can give any additional | |
9 | + # model classes that should also go into this index as parameters. | |
10 | + # Useful when using the :single_index option. | |
11 | + # Note that attributes named the same in different models will share | |
12 | + # the same field options in the shared index. | |
13 | + def rebuild_index(*models) | |
14 | + models << self unless models.include?(self) | |
15 | + aaf_index.rebuild_index models.map(&:to_s) | |
16 | + index_dir = find_last_index_version(aaf_configuration[:index_base_dir]) unless aaf_configuration[:remote] | |
17 | + end | |
18 | + | |
19 | + # runs across all records yielding those to be indexed when the index is rebuilt | |
20 | + def records_for_rebuild(batch_size = 1000) | |
21 | + transaction do | |
22 | + if connection.class.name =~ /Mysql/ && primary_key == 'id' | |
23 | + logger.info "using mysql specific batched find :all" | |
24 | + offset = 0 | |
25 | + while (rows = find :all, :conditions => ["id > ?", offset ], :limit => batch_size).any? | |
26 | + offset = rows.last.id | |
27 | + yield rows, offset | |
28 | + end | |
29 | + else | |
30 | + # sql server adapter won't batch correctly without defined ordering | |
31 | + order = "#{primary_key} ASC" if connection.class.name =~ /SQLServer/ | |
32 | + 0.step(self.count, batch_size) do |offset| | |
33 | + yield find( :all, :limit => batch_size, :offset => offset, :order => order ), offset | |
34 | + end | |
35 | + end | |
36 | + end | |
37 | + end | |
38 | + | |
39 | + # Switches this class to a new index located in dir. | |
40 | + # Used by the DRb server when switching to a new index version. | |
41 | + def index_dir=(dir) | |
42 | + logger.debug "changing index dir to #{dir}" | |
43 | + aaf_configuration[:index_dir] = aaf_configuration[:ferret][:path] = dir | |
44 | + aaf_index.reopen! | |
45 | + logger.debug "index dir is now #{dir}" | |
46 | + end | |
47 | + | |
48 | + # Retrieve the index instance for this model class. This can either be a | |
49 | + # LocalIndex, or a RemoteIndex instance. | |
50 | + # | |
51 | + # Index instances are stored in a hash, using the index directory | |
52 | + # as the key. So model classes sharing a single index will share their | |
53 | + # Index object, too. | |
54 | + def aaf_index | |
55 | + ActsAsFerret::ferret_indexes[aaf_configuration[:index_dir]] ||= create_index_instance | |
56 | + end | |
57 | + | |
58 | + # Finds instances by searching the Ferret index. Terms are ANDed by default, use | |
59 | + # OR between terms for ORed queries. Or specify +:or_default => true+ in the | |
60 | + # +:ferret+ options hash of acts_as_ferret. | |
61 | + # | |
62 | + # == options: | |
63 | + # offset:: first hit to retrieve (useful for paging) | |
64 | + # limit:: number of hits to retrieve, or :all to retrieve | |
65 | + # all results | |
66 | + # lazy:: Array of field names whose contents should be read directly | |
67 | + # from the index. Those fields have to be marked | |
68 | + # +:store => :yes+ in their field options. Give true to get all | |
69 | + # stored fields. Note that if you have a shared index, you have | |
70 | + # to explicitly state the fields you want to fetch, true won't | |
71 | + # work here) | |
72 | + # models:: only for single_index scenarios: an Array of other Model classes to | |
73 | + # include in this search. Use :all to query all models. | |
74 | + # | |
75 | + # +find_options+ is a hash passed on to active_record's find when | |
76 | + # retrieving the data from db, useful to i.e. prefetch relationships with | |
77 | + # :include or to specify additional filter criteria with :conditions. | |
78 | + # | |
79 | + # This method returns a +SearchResults+ instance, which really is an Array that has | |
80 | + # been decorated with a total_hits attribute holding the total number of hits. | |
81 | + # | |
82 | + # Please keep in mind that the number of total hits might be wrong if you specify | |
83 | + # both ferret options and active record find_options that somehow limit the result | |
84 | + # set (e.g. +:num_docs+ and some +:conditions+). | |
85 | + def find_with_ferret(q, options = {}, find_options = {}) | |
86 | + total_hits, result = find_records_lazy_or_not q, options, find_options | |
87 | + logger.debug "Query: #{q}\ntotal hits: #{total_hits}, results delivered: #{result.size}" | |
88 | + return SearchResults.new(result, total_hits) | |
89 | + end | |
90 | + alias find_by_contents find_with_ferret | |
91 | + | |
92 | + | |
93 | + | |
94 | + # Returns the total number of hits for the given query | |
95 | + # To count the results of a multi_search query, specify an array of | |
96 | + # class names with the :models option. | |
97 | + def total_hits(q, options={}) | |
98 | + if models = options[:models] | |
99 | + options[:models] = add_self_to_model_list_if_necessary(models).map(&:to_s) | |
100 | + end | |
101 | + aaf_index.total_hits(q, options) | |
102 | + end | |
103 | + | |
104 | + # Finds instance model name, ids and scores by contents. | |
105 | + # Useful e.g. if you want to search across models or do not want to fetch | |
106 | + # all result records (yet). | |
107 | + # | |
108 | + # Options are the same as for find_by_contents | |
109 | + # | |
110 | + # A block can be given too, it will be executed with every result: | |
111 | + # find_id_by_contents(q, options) do |model, id, score| | |
112 | + # id_array << id | |
113 | + # scores_by_id[id] = score | |
114 | + # end | |
115 | + # NOTE: in case a block is given, only the total_hits value will be returned | |
116 | + # instead of the [total_hits, results] array! | |
117 | + # | |
118 | + def find_id_by_contents(q, options = {}, &block) | |
119 | + deprecated_options_support(options) | |
120 | + aaf_index.find_id_by_contents(q, options, &block) | |
121 | + end | |
122 | + | |
123 | + # requires the store_class_name option of acts_as_ferret to be true | |
124 | + # for all models queried this way. | |
125 | + def multi_search(query, additional_models = [], options = {}, find_options = {}) | |
126 | + result = [] | |
127 | + | |
128 | + if options[:lazy] | |
129 | + logger.warn "find_options #{find_options} are ignored because :lazy => true" unless find_options.empty? | |
130 | + total_hits = id_multi_search(query, additional_models, options) do |model, id, score, data| | |
131 | + result << FerretResult.new(model, id, score, data) | |
132 | + end | |
133 | + else | |
134 | + id_arrays = {} | |
135 | + rank = 0 | |
136 | + total_hits = id_multi_search(query, additional_models, options) do |model, id, score, data| | |
137 | + id_arrays[model] ||= {} | |
138 | + id_arrays[model][id] = [ rank += 1, score ] | |
139 | + end | |
140 | + result = retrieve_records(id_arrays, find_options) | |
141 | + end | |
142 | + | |
143 | + SearchResults.new(result, total_hits) | |
144 | + end | |
145 | + | |
146 | + # returns an array of hashes, each containing :class_name, | |
147 | + # :id and :score for a hit. | |
148 | + # | |
149 | + # if a block is given, class_name, id and score of each hit will | |
150 | + # be yielded, and the total number of hits is returned. | |
151 | + def id_multi_search(query, additional_models = [], options = {}, &proc) | |
152 | + deprecated_options_support(options) | |
153 | + additional_models = add_self_to_model_list_if_necessary(additional_models) | |
154 | + aaf_index.id_multi_search(query, additional_models.map(&:to_s), options, &proc) | |
155 | + end | |
156 | + | |
157 | + | |
158 | + protected | |
159 | + | |
160 | + def add_self_to_model_list_if_necessary(models) | |
161 | + models = [ models ] unless models.is_a? Array | |
162 | + models << self unless models.include?(self) | |
163 | + end | |
164 | + | |
165 | + def find_records_lazy_or_not(q, options = {}, find_options = {}) | |
166 | + if options[:lazy] | |
167 | + logger.warn "find_options #{find_options} are ignored because :lazy => true" unless find_options.empty? | |
168 | + lazy_find_by_contents q, options | |
169 | + else | |
170 | + ar_find_by_contents q, options, find_options | |
171 | + end | |
172 | + end | |
173 | + | |
174 | + def ar_find_by_contents(q, options = {}, find_options = {}) | |
175 | + result_ids = {} | |
176 | + total_hits = find_id_by_contents(q, options) do |model, id, score, data| | |
177 | + # stores ids, index of each id for later ordering of | |
178 | + # results, and score | |
179 | + result_ids[id] = [ result_ids.size + 1, score ] | |
180 | + end | |
181 | + | |
182 | + result = retrieve_records( { self.name => result_ids }, find_options ) | |
183 | + | |
184 | + if find_options[:conditions] | |
185 | + if options[:limit] != :all | |
186 | + # correct result size if the user specified conditions | |
187 | + # wenn conditions: options[:limit] != :all --> ferret-query mit :all wiederholen und select count machen | |
188 | + result_ids = {} | |
189 | + find_id_by_contents(q, options.update(:limit => :all)) do |model, id, score, data| | |
190 | + result_ids[id] = [ result_ids.size + 1, score ] | |
191 | + end | |
192 | + total_hits = count_records( { self.name => result_ids }, find_options ) | |
193 | + else | |
194 | + total_hits = result.length | |
195 | + end | |
196 | + end | |
197 | + | |
198 | + [ total_hits, result ] | |
199 | + end | |
200 | + | |
201 | + def lazy_find_by_contents(q, options = {}) | |
202 | + result = [] | |
203 | + total_hits = find_id_by_contents(q, options) do |model, id, score, data| | |
204 | + result << FerretResult.new(model, id, score, data) | |
205 | + end | |
206 | + [ total_hits, result ] | |
207 | + end | |
208 | + | |
209 | + | |
210 | + def model_find(model, id, find_options = {}) | |
211 | + model.constantize.find(id, find_options) | |
212 | + end | |
213 | + | |
214 | + # retrieves search result records from a data structure like this: | |
215 | + # { 'Model1' => { '1' => [ rank, score ], '2' => [ rank, score ] } | |
216 | + # | |
217 | + # TODO: in case of STI AR will filter out hits from other | |
218 | + # classes for us, but this | |
219 | + # will lead to less results retrieved --> scoping of ferret query | |
220 | + # to self.class is still needed. | |
221 | + # from the ferret ML (thanks Curtis Hatter) | |
222 | + # > I created a method in my base STI class so I can scope my query. For scoping | |
223 | + # > I used something like the following line: | |
224 | + # > | |
225 | + # > query << " role:#{self.class.eql?(Contents) '*' : self.class}" | |
226 | + # > | |
227 | + # > Though you could make it more generic by simply asking | |
228 | + # > "self.descends_from_active_record?" which is how rails decides if it should | |
229 | + # > scope your "find" query for STI models. You can check out "base.rb" in | |
230 | + # > activerecord to see that. | |
231 | + # but maybe better do the scoping in find_id_by_contents... | |
232 | + def retrieve_records(id_arrays, find_options = {}) | |
233 | + result = [] | |
234 | + # get objects for each model | |
235 | + id_arrays.each do |model, id_array| | |
236 | + next if id_array.empty? | |
237 | + begin | |
238 | + model = model.constantize | |
239 | + rescue | |
240 | + raise "Please use ':store_class_name => true' if you want to use multi_search.\n#{$!}" | |
241 | + end | |
242 | + | |
243 | + # check for include association that might only exist on some models in case of multi_search | |
244 | + filtered_include_options = [] | |
245 | + if include_options = find_options[:include] | |
246 | + include_options.each do |include_option| | |
247 | + filtered_include_options << include_option if model.reflections.has_key?(include_option.is_a?(Hash) ? include_option.keys[0].to_sym : include_option.to_sym) | |
248 | + end | |
249 | + end | |
250 | + filtered_include_options=nil if filtered_include_options.empty? | |
251 | + | |
252 | + # fetch | |
253 | + tmp_result = nil | |
254 | + model.send(:with_scope, :find => find_options) do | |
255 | + tmp_result = model.find( :all, :conditions => [ | |
256 | + "#{model.table_name}.#{model.primary_key} in (?)", id_array.keys ], | |
257 | + :include => filtered_include_options ) | |
258 | + end | |
259 | + | |
260 | + # set scores and rank | |
261 | + tmp_result.each do |record| | |
262 | + record.ferret_rank, record.ferret_score = id_array[record.id.to_s] | |
263 | + end | |
264 | + # merge with result array | |
265 | + result.concat tmp_result | |
266 | + end | |
267 | + | |
268 | + # order results as they were found by ferret, unless an AR :order | |
269 | + # option was given | |
270 | + result.sort! { |a, b| a.ferret_rank <=> b.ferret_rank } unless find_options[:order] | |
271 | + return result | |
272 | + end | |
273 | + | |
274 | + def count_records(id_arrays, find_options = {}) | |
275 | + count = 0 | |
276 | + id_arrays.each do |model, id_array| | |
277 | + next if id_array.empty? | |
278 | + begin | |
279 | + model = model.constantize | |
280 | + model.send(:with_scope, :find => find_options) do | |
281 | + count += model.count(:conditions => [ "#{model.table_name}.#{model.primary_key} in (?)", | |
282 | + id_array.keys ]) | |
283 | + end | |
284 | + rescue TypeError | |
285 | + raise "#{model} must use :store_class_name option if you want to use multi_search against it.\n#{$!}" | |
286 | + end | |
287 | + end | |
288 | + count | |
289 | + end | |
290 | + | |
291 | + def deprecated_options_support(options) | |
292 | + if options[:num_docs] | |
293 | + logger.warn ":num_docs is deprecated, use :limit instead!" | |
294 | + options[:limit] ||= options[:num_docs] | |
295 | + end | |
296 | + if options[:first_doc] | |
297 | + logger.warn ":first_doc is deprecated, use :offset instead!" | |
298 | + options[:offset] ||= options[:first_doc] | |
299 | + end | |
300 | + end | |
301 | + | |
302 | + # creates a new Index instance. | |
303 | + def create_index_instance | |
304 | + if aaf_configuration[:remote] | |
305 | + RemoteIndex | |
306 | + elsif aaf_configuration[:single_index] | |
307 | + SharedIndex | |
308 | + else | |
309 | + LocalIndex | |
310 | + end.new(aaf_configuration) | |
311 | + end | |
312 | + | |
313 | + end | |
314 | + | |
315 | +end | |
316 | + | ... | ... |
... | ... | @@ -0,0 +1,21 @@ |
1 | +# Ferret DRb server Capistrano tasks | |
2 | +# Usage: | |
3 | +# Add require 'vendor/plugins/acts_as_ferret/lib/ferret_cap_tasks' to your | |
4 | +# config/deploy.rb | |
5 | +# call ferret.restart where you restart your Mongrels. | |
6 | +# ferret.stop and ferret.start are available, too. | |
7 | +module FerretCapTasks | |
8 | + def start | |
9 | + run "cd #{current_path}; RAILS_ENV=production script/ferret_start" | |
10 | + end | |
11 | + | |
12 | + def stop | |
13 | + run "cd #{current_path}; RAILS_ENV=production script/ferret_stop" | |
14 | + end | |
15 | + | |
16 | + def restart | |
17 | + stop | |
18 | + start | |
19 | + end | |
20 | +end | |
21 | +Capistrano.plugin :ferret, FerretCapTasks | ... | ... |
... | ... | @@ -0,0 +1,81 @@ |
1 | +module Ferret | |
2 | + | |
3 | + | |
4 | + class Index::Index | |
5 | + attr_accessor :batch_size | |
6 | + attr_accessor :logger | |
7 | + | |
8 | + def index_models(models) | |
9 | + models.each { |model| index_model model } | |
10 | + flush | |
11 | + optimize | |
12 | + close | |
13 | + ActsAsFerret::close_multi_indexes | |
14 | + end | |
15 | + | |
16 | + def index_model(model) | |
17 | + @batch_size ||= 0 | |
18 | + work_done = 0 | |
19 | + batch_time = 0 | |
20 | + logger.info "reindexing model #{model.name}" | |
21 | + | |
22 | + model_count = model.count.to_f | |
23 | + model.records_for_rebuild(@batch_size) do |records, offset| | |
24 | + #records = [ records ] unless records.is_a?(Array) | |
25 | + batch_time = measure_time { | |
26 | + records.each { |rec| self << rec.to_doc if rec.ferret_enabled?(true) } | |
27 | + }.to_f | |
28 | + work_done = offset.to_f / model_count * 100.0 if model_count > 0 | |
29 | + remaining_time = ( batch_time / @batch_size ) * ( model_count - offset + @batch_size ) | |
30 | + logger.info "reindex model #{model.name} : #{'%.2f' % work_done}% complete : #{'%.2f' % remaining_time} secs to finish" | |
31 | + end | |
32 | + end | |
33 | + | |
34 | + def measure_time | |
35 | + t1 = Time.now | |
36 | + yield | |
37 | + Time.now - t1 | |
38 | + end | |
39 | + | |
40 | + end | |
41 | + | |
42 | + | |
43 | + # add marshalling support to SortFields | |
44 | + class Search::SortField | |
45 | + def _dump(depth) | |
46 | + to_s | |
47 | + end | |
48 | + | |
49 | + def self._load(string) | |
50 | + case string | |
51 | + when /<DOC(_ID)?>!/ : Ferret::Search::SortField::DOC_ID_REV | |
52 | + when /<DOC(_ID)?>/ : Ferret::Search::SortField::DOC_ID | |
53 | + when '<SCORE>!' : Ferret::Search::SortField::SCORE_REV | |
54 | + when '<SCORE>' : Ferret::Search::SortField::SCORE | |
55 | + when /^(\w+):<(\w+)>(!)?$/ : new($1.to_sym, :type => $2.to_sym, :reverse => !$3.nil?) | |
56 | + else raise "invalid value: #{string}" | |
57 | + end | |
58 | + end | |
59 | + end | |
60 | + | |
61 | + # add marshalling support to Sort | |
62 | + class Search::Sort | |
63 | + def _dump(depth) | |
64 | + to_s | |
65 | + end | |
66 | + | |
67 | + def self._load(string) | |
68 | + # we exclude the last <DOC> sorting as it is appended by new anyway | |
69 | + if string =~ /^Sort\[(.*?)(<DOC>(!)?)?\]$/ | |
70 | + sort_fields = $1.split(',').map do |value| | |
71 | + value.strip! | |
72 | + Ferret::Search::SortField._load value unless value.blank? | |
73 | + end | |
74 | + new sort_fields.compact | |
75 | + else | |
76 | + raise "invalid value: #{string}" | |
77 | + end | |
78 | + end | |
79 | + end | |
80 | + | |
81 | +end | ... | ... |
... | ... | @@ -0,0 +1,36 @@ |
1 | +module ActsAsFerret | |
2 | + | |
3 | + # mixed into the FerretResult and AR classes calling acts_as_ferret | |
4 | + module ResultAttributes | |
5 | + # holds the score this record had when it was found via | |
6 | + # acts_as_ferret | |
7 | + attr_accessor :ferret_score | |
8 | + | |
9 | + attr_accessor :ferret_rank | |
10 | + end | |
11 | + | |
12 | + class FerretResult | |
13 | + include ResultAttributes | |
14 | + attr_accessor :id | |
15 | + | |
16 | + def initialize(model, id, score, data = {}) | |
17 | + @model = model.constantize | |
18 | + @id = id | |
19 | + @ferret_score = score | |
20 | + @data = data | |
21 | + end | |
22 | + | |
23 | + def method_missing(method, *args) | |
24 | + if @ar_record || @data[method].nil? | |
25 | + ferret_load_record unless @ar_record | |
26 | + @ar_record.send method, *args | |
27 | + else | |
28 | + @data[method] | |
29 | + end | |
30 | + end | |
31 | + | |
32 | + def ferret_load_record | |
33 | + @ar_record = @model.find(id) | |
34 | + end | |
35 | + end | |
36 | +end | ... | ... |
... | ... | @@ -0,0 +1,131 @@ |
1 | +require 'drb' | |
2 | +require 'thread' | |
3 | +require 'yaml' | |
4 | +require 'erb' | |
5 | + | |
6 | + | |
7 | +module ActsAsFerret | |
8 | + | |
9 | + module Remote | |
10 | + | |
11 | + module Config | |
12 | + class << self | |
13 | + DEFAULTS = { | |
14 | + 'host' => 'localhost', | |
15 | + 'port' => '9009' | |
16 | + } | |
17 | + # read connection settings from config file | |
18 | + def load(file = "#{RAILS_ROOT}/config/ferret_server.yml") | |
19 | + config = DEFAULTS.merge(YAML.load(ERB.new(IO.read(file)).result)) | |
20 | + if config = config[RAILS_ENV] | |
21 | + config[:uri] = "druby://#{config['host']}:#{config['port']}" | |
22 | + return config | |
23 | + end | |
24 | + {} | |
25 | + end | |
26 | + end | |
27 | + end | |
28 | + | |
29 | + # This class acts as a drb server listening for indexing and | |
30 | + # search requests from models declared to 'acts_as_ferret :remote => true' | |
31 | + # | |
32 | + # Usage: | |
33 | + # - modify RAILS_ROOT/config/ferret_server.yml to suit your needs. | |
34 | + # - environments for which no section in the config file exists will use | |
35 | + # the index locally (good for unit tests/development mode) | |
36 | + # - run script/ferret_start to start the server: | |
37 | + # RAILS_ENV=production script/ferret_start | |
38 | + # | |
39 | + class Server | |
40 | + | |
41 | + cattr_accessor :running | |
42 | + | |
43 | + def self.start(uri = nil) | |
44 | + ActiveRecord::Base.allow_concurrency = true | |
45 | + ActiveRecord::Base.logger = Logger.new("#{RAILS_ROOT}/log/ferret_server.log") | |
46 | + uri ||= ActsAsFerret::Remote::Config.load[:uri] | |
47 | + DRb.start_service(uri, ActsAsFerret::Remote::Server.new) | |
48 | + self.running = true | |
49 | + end | |
50 | + | |
51 | + def initialize | |
52 | + @logger = ActiveRecord::Base.logger | |
53 | + end | |
54 | + | |
55 | + # handles all incoming method calls, and sends them on to the LocalIndex | |
56 | + # instance of the correct model class. | |
57 | + # | |
58 | + # Calls are not queued atm, so this will block until the call returned. | |
59 | + # | |
60 | + def method_missing(name, *args) | |
61 | + @logger.debug "\#method_missing(#{name.inspect}, #{args.inspect})" | |
62 | + with_class args.shift do |clazz| | |
63 | + begin | |
64 | + clazz.aaf_index.send name, *args | |
65 | + rescue NoMethodError | |
66 | + @logger.debug "no luck, trying to call class method instead" | |
67 | + clazz.send name, *args | |
68 | + end | |
69 | + end | |
70 | + rescue | |
71 | + @logger.error "ferret server error #{$!}\n#{$!.backtrace.join '\n'}" | |
72 | + raise | |
73 | + end | |
74 | + | |
75 | + # make sure we have a versioned index in place, building one if necessary | |
76 | + def ensure_index_exists(class_name) | |
77 | + @logger.debug "DRb server: ensure_index_exists for class #{class_name}" | |
78 | + with_class class_name do |clazz| | |
79 | + dir = clazz.aaf_configuration[:index_dir] | |
80 | + unless File.directory?(dir) && File.file?(File.join(dir, 'segments')) && dir =~ %r{/\d+(_\d+)?$} | |
81 | + rebuild_index(clazz) | |
82 | + end | |
83 | + end | |
84 | + end | |
85 | + | |
86 | + # hides LocalIndex#rebuild_index to implement index versioning | |
87 | + def rebuild_index(clazz, *models) | |
88 | + with_class clazz do |clazz| | |
89 | + models = models.flatten.uniq.map(&:constantize) | |
90 | + models << clazz unless models.include?(clazz) | |
91 | + index = new_index_for(clazz, models) | |
92 | + @logger.debug "DRb server: rebuild index for class(es) #{models.inspect} in #{index.options[:path]}" | |
93 | + index.index_models models | |
94 | + new_version = File.join clazz.aaf_configuration[:index_base_dir], Time.now.utc.strftime('%Y%m%d%H%M%S') | |
95 | + # create a unique directory name (needed for unit tests where | |
96 | + # multiple rebuilds per second may occur) | |
97 | + if File.exists?(new_version) | |
98 | + i = 0 | |
99 | + i+=1 while File.exists?("#{new_version}_#{i}") | |
100 | + new_version << "_#{i}" | |
101 | + end | |
102 | + | |
103 | + File.rename index.options[:path], new_version | |
104 | + clazz.index_dir = new_version | |
105 | + end | |
106 | + end | |
107 | + | |
108 | + | |
109 | + protected | |
110 | + | |
111 | + def with_class(clazz, *args) | |
112 | + clazz = clazz.constantize if String === clazz | |
113 | + yield clazz, *args | |
114 | + end | |
115 | + | |
116 | + def new_index_for(clazz, models) | |
117 | + aaf_configuration = clazz.aaf_configuration | |
118 | + ferret_cfg = aaf_configuration[:ferret].dup | |
119 | + ferret_cfg.update :auto_flush => false, | |
120 | + :create => true, | |
121 | + :field_infos => ActsAsFerret::field_infos(models), | |
122 | + :path => File.join(aaf_configuration[:index_base_dir], 'rebuild') | |
123 | + returning Ferret::Index::Index.new ferret_cfg do |i| | |
124 | + i.batch_size = aaf_configuration[:reindex_batch_size] | |
125 | + i.logger = @logger | |
126 | + end | |
127 | + end | |
128 | + | |
129 | + end | |
130 | + end | |
131 | +end | ... | ... |
... | ... | @@ -0,0 +1,31 @@ |
1 | +module ActsAsFerret | |
2 | + | |
3 | + # base class for local and remote indexes | |
4 | + class AbstractIndex | |
5 | + | |
6 | + attr_reader :aaf_configuration | |
7 | + attr_accessor :logger | |
8 | + def initialize(aaf_configuration) | |
9 | + @aaf_configuration = aaf_configuration | |
10 | + @logger = Logger.new("#{RAILS_ROOT}/log/ferret_index.log") | |
11 | + end | |
12 | + | |
13 | + class << self | |
14 | + def proxy_method(name, *args) | |
15 | + define_method name do |*args| | |
16 | + @server.send name, model_class_name, *args | |
17 | + end | |
18 | + end | |
19 | + | |
20 | + def index_proxy_method(*names) | |
21 | + names.each do |name| | |
22 | + define_method name do |*args| | |
23 | + @server.send :"index_#{name}", model_class_name, *args | |
24 | + end | |
25 | + end | |
26 | + end | |
27 | + | |
28 | + end | |
29 | + end | |
30 | + | |
31 | +end | ... | ... |
... | ... | @@ -0,0 +1,126 @@ |
1 | +module ActsAsFerret #:nodoc: | |
2 | + | |
3 | + module InstanceMethods | |
4 | + include ResultAttributes | |
5 | + | |
6 | + # Returns an array of strings with the matches highlighted. The +query+ can | |
7 | + # either be a String or a Ferret::Search::Query object. | |
8 | + # | |
9 | + # === Options | |
10 | + # | |
11 | + # field:: field to take the content from. This field has | |
12 | + # to have it's content stored in the index | |
13 | + # (:store => :yes in your call to aaf). If not | |
14 | + # given, all stored fields are searched, and the | |
15 | + # highlighted content found in all of them is returned. | |
16 | + # set :highlight => :no in the field options to | |
17 | + # avoid highlighting of contents from a :stored field. | |
18 | + # excerpt_length:: Default: 150. Length of excerpt to show. Highlighted | |
19 | + # terms will be in the centre of the excerpt. | |
20 | + # num_excerpts:: Default: 2. Number of excerpts to return. | |
21 | + # pre_tag:: Default: "<em>". Tag to place to the left of the | |
22 | + # match. | |
23 | + # post_tag:: Default: "</em>". This tag should close the | |
24 | + # +:pre_tag+. | |
25 | + # ellipsis:: Default: "...". This is the string that is appended | |
26 | + # at the beginning and end of excerpts (unless the | |
27 | + # excerpt hits the start or end of the field. You'll | |
28 | + # probably want to change this so a Unicode elipsis | |
29 | + # character. | |
30 | + def highlight(query, options = {}) | |
31 | + self.class.aaf_index.highlight(id, self.class.name, query, options) | |
32 | + end | |
33 | + | |
34 | + # re-eneable ferret indexing after a call to #disable_ferret | |
35 | + def ferret_enable; @ferret_disabled = nil end | |
36 | + | |
37 | + # returns true if ferret indexing is enabled | |
38 | + # the optional parameter will be true if the method is called by rebuild_index, | |
39 | + # and false otherwise. I.e. useful to enable a model only for indexing during | |
40 | + # scheduled reindex runs. | |
41 | + def ferret_enabled?(is_rebuild = false); @ferret_disabled.nil? end | |
42 | + | |
43 | + # Disable Ferret for a specified amount of time. ::once will disable | |
44 | + # Ferret for the next call to #save (this is the default), ::always will | |
45 | + # do so for all subsequent calls. | |
46 | + # To manually trigger reindexing of a record, you can call #ferret_update | |
47 | + # directly. | |
48 | + # | |
49 | + # When given a block, this will be executed without any ferret indexing of | |
50 | + # this object taking place. The optional argument in this case can be used | |
51 | + # to indicate if the object should be indexed after executing the block | |
52 | + # (::index_when_finished). Automatic Ferret indexing of this object will be | |
53 | + # turned on after the block has been executed. If passed ::index_when_true, | |
54 | + # the index will only be updated if the block evaluated not to false or nil. | |
55 | + def disable_ferret(option = :once) | |
56 | + if block_given? | |
57 | + @ferret_disabled = :always | |
58 | + result = yield | |
59 | + ferret_enable | |
60 | + ferret_update if option == :index_when_finished || (option == :index_when_true && result) | |
61 | + result | |
62 | + elsif [:once, :always].include?(option) | |
63 | + @ferret_disabled = option | |
64 | + else | |
65 | + raise ArgumentError.new("Invalid Argument #{option}") | |
66 | + end | |
67 | + end | |
68 | + | |
69 | + # add to index | |
70 | + def ferret_create | |
71 | + if ferret_enabled? | |
72 | + logger.debug "ferret_create/update: #{self.class.name} : #{self.id}" | |
73 | + self.class.aaf_index << self | |
74 | + else | |
75 | + ferret_enable if @ferret_disabled == :once | |
76 | + end | |
77 | + true # signal success to AR | |
78 | + end | |
79 | + alias :ferret_update :ferret_create | |
80 | + | |
81 | + | |
82 | + # remove from index | |
83 | + def ferret_destroy | |
84 | + logger.debug "ferret_destroy: #{self.class.name} : #{self.id}" | |
85 | + begin | |
86 | + self.class.aaf_index.remove self.id, self.class.name | |
87 | + rescue | |
88 | + logger.warn("Could not find indexed value for this object: #{$!}\n#{$!.backtrace}") | |
89 | + end | |
90 | + true # signal success to AR | |
91 | + end | |
92 | + | |
93 | + # turn this instance into a ferret document (which basically is a hash of | |
94 | + # fieldname => value pairs) | |
95 | + def to_doc | |
96 | + logger.debug "creating doc for class: #{self.class.name}, id: #{self.id}" | |
97 | + returning doc = Ferret::Document.new do | |
98 | + # store the id of each item | |
99 | + doc[:id] = self.id | |
100 | + | |
101 | + # store the class name if configured to do so | |
102 | + doc[:class_name] = self.class.name if aaf_configuration[:store_class_name] | |
103 | + | |
104 | + # iterate through the fields and add them to the document | |
105 | + aaf_configuration[:ferret_fields].each_pair do |field, config| | |
106 | + doc[field] = self.send("#{field}_to_ferret") unless config[:ignore] | |
107 | + end | |
108 | + end | |
109 | + end | |
110 | + | |
111 | + def document_number | |
112 | + self.class.aaf_index.document_number(id, self.class.name) | |
113 | + end | |
114 | + | |
115 | + def query_for_record | |
116 | + self.class.aaf_index.query_for_record(id, self.class.name) | |
117 | + end | |
118 | + | |
119 | + def content_for_field_name(field) | |
120 | + self[field] || self.instance_variable_get("@#{field.to_s}".to_sym) || self.send(field.to_sym) | |
121 | + end | |
122 | + | |
123 | + | |
124 | + end | |
125 | + | |
126 | +end | ... | ... |
... | ... | @@ -0,0 +1,209 @@ |
1 | +module ActsAsFerret | |
2 | + | |
3 | + class LocalIndex < AbstractIndex | |
4 | + include MoreLikeThis::IndexMethods | |
5 | + | |
6 | + | |
7 | + def initialize(aaf_configuration) | |
8 | + super | |
9 | + ensure_index_exists | |
10 | + end | |
11 | + | |
12 | + def reopen! | |
13 | + if @ferret_index | |
14 | + @ferret_index.close | |
15 | + @ferret_index = nil | |
16 | + end | |
17 | + logger.debug "reopening index at #{aaf_configuration[:ferret][:path]}" | |
18 | + ferret_index | |
19 | + end | |
20 | + | |
21 | + # The 'real' Ferret Index instance | |
22 | + def ferret_index | |
23 | + ensure_index_exists | |
24 | + returning @ferret_index ||= Ferret::Index::Index.new(aaf_configuration[:ferret]) do | |
25 | + @ferret_index.batch_size = aaf_configuration[:reindex_batch_size] | |
26 | + @ferret_index.logger = logger | |
27 | + end | |
28 | + end | |
29 | + | |
30 | + # Checks for the presence of a segments file in the index directory | |
31 | + # Rebuilds the index if none exists. | |
32 | + def ensure_index_exists | |
33 | + logger.debug "LocalIndex: ensure_index_exists at #{aaf_configuration[:index_dir]}" | |
34 | + unless File.file? "#{aaf_configuration[:index_dir]}/segments" | |
35 | + ActsAsFerret::ensure_directory(aaf_configuration[:index_dir]) | |
36 | + close | |
37 | + rebuild_index | |
38 | + end | |
39 | + end | |
40 | + | |
41 | + # Closes the underlying index instance | |
42 | + def close | |
43 | + @ferret_index.close if @ferret_index | |
44 | + rescue StandardError | |
45 | + # is raised when index already closed | |
46 | + ensure | |
47 | + @ferret_index = nil | |
48 | + end | |
49 | + | |
50 | + # rebuilds the index from all records of the model class this index belongs | |
51 | + # to. Arguments can be given in shared index scenarios to name multiple | |
52 | + # model classes to include in the index | |
53 | + def rebuild_index(*models) | |
54 | + models << aaf_configuration[:class_name] unless models.include?(aaf_configuration[:class_name]) | |
55 | + models = models.flatten.uniq.map(&:constantize) | |
56 | + logger.debug "rebuild index: #{models.inspect}" | |
57 | + index = Ferret::Index::Index.new(aaf_configuration[:ferret].dup.update(:auto_flush => false, | |
58 | + :field_infos => ActsAsFerret::field_infos(models), | |
59 | + :create => true)) | |
60 | + index.batch_size = aaf_configuration[:reindex_batch_size] | |
61 | + index.logger = logger | |
62 | + index.index_models models | |
63 | + end | |
64 | + | |
65 | + # Parses the given query string into a Ferret Query object. | |
66 | + def process_query(query) | |
67 | + # work around ferret bug in #process_query (doesn't ensure the | |
68 | + # reader is open) | |
69 | + ferret_index.synchronize do | |
70 | + ferret_index.send(:ensure_reader_open) | |
71 | + original_query = ferret_index.process_query(query) | |
72 | + end | |
73 | + end | |
74 | + | |
75 | + # Total number of hits for the given query. | |
76 | + # To count the results of a multi_search query, specify an array of | |
77 | + # class names with the :models option. | |
78 | + def total_hits(query, options = {}) | |
79 | + index = (models = options.delete(:models)) ? multi_index(models) : ferret_index | |
80 | + index.search(query, options).total_hits | |
81 | + end | |
82 | + | |
83 | + def determine_lazy_fields(options = {}) | |
84 | + stored_fields = options[:lazy] | |
85 | + if stored_fields && !(Array === stored_fields) | |
86 | + stored_fields = aaf_configuration[:ferret_fields].select { |field, config| config[:store] == :yes }.map(&:first) | |
87 | + end | |
88 | + logger.debug "stored_fields: #{stored_fields}" | |
89 | + return stored_fields | |
90 | + end | |
91 | + | |
92 | + # Queries the Ferret index to retrieve model class, id, score and the | |
93 | + # values of any fields stored in the index for each hit. | |
94 | + # If a block is given, these are yielded and the number of total hits is | |
95 | + # returned. Otherwise [total_hits, result_array] is returned. | |
96 | + def find_id_by_contents(query, options = {}) | |
97 | + result = [] | |
98 | + index = ferret_index | |
99 | + logger.debug "query: #{ferret_index.process_query query}" # TODO only enable this for debugging purposes | |
100 | + lazy_fields = determine_lazy_fields options | |
101 | + | |
102 | + total_hits = index.search_each(query, options) do |hit, score| | |
103 | + doc = index[hit] | |
104 | + model = aaf_configuration[:store_class_name] ? doc[:class_name] : aaf_configuration[:class_name] | |
105 | + # fetch stored fields if lazy loading | |
106 | + data = {} | |
107 | + lazy_fields.each { |field| data[field] = doc[field] } if lazy_fields | |
108 | + if block_given? | |
109 | + yield model, doc[:id], score, data | |
110 | + else | |
111 | + result << { :model => model, :id => doc[:id], :score => score, :data => data } | |
112 | + end | |
113 | + end | |
114 | + #logger.debug "id_score_model array: #{result.inspect}" | |
115 | + return block_given? ? total_hits : [total_hits, result] | |
116 | + end | |
117 | + | |
118 | + # Queries multiple Ferret indexes to retrieve model class, id and score for | |
119 | + # each hit. Use the models parameter to give the list of models to search. | |
120 | + # If a block is given, model, id and score are yielded and the number of | |
121 | + # total hits is returned. Otherwise [total_hits, result_array] is returned. | |
122 | + def id_multi_search(query, models, options = {}) | |
123 | + index = multi_index(models) | |
124 | + result = [] | |
125 | + lazy_fields = determine_lazy_fields options | |
126 | + total_hits = index.search_each(query, options) do |hit, score| | |
127 | + doc = index[hit] | |
128 | + # fetch stored fields if lazy loading | |
129 | + data = {} | |
130 | + lazy_fields.each { |field| data[field] = doc[field] } if lazy_fields | |
131 | + raise "':store_class_name => true' required for multi_search to work" if doc[:class_name].blank? | |
132 | + if block_given? | |
133 | + yield doc[:class_name], doc[:id], score, doc, data | |
134 | + else | |
135 | + result << { :model => doc[:class_name], :id => doc[:id], :score => score, :data => data } | |
136 | + end | |
137 | + end | |
138 | + return block_given? ? total_hits : [ total_hits, result ] | |
139 | + end | |
140 | + | |
141 | + ###################################### | |
142 | + # methods working on a single record | |
143 | + # called from instance_methods, here to simplify interfacing with the | |
144 | + # remote ferret server | |
145 | + # TODO having to pass id and class_name around like this isn't nice | |
146 | + ###################################### | |
147 | + | |
148 | + # add record to index | |
149 | + # record may be the full AR object, a Ferret document instance or a Hash | |
150 | + def add(record) | |
151 | + record = record.to_doc unless Hash === record || Ferret::Document === record | |
152 | + ferret_index << record | |
153 | + end | |
154 | + alias << add | |
155 | + | |
156 | + # delete record from index | |
157 | + def remove(id, class_name) | |
158 | + ferret_index.query_delete query_for_record(id, class_name) | |
159 | + end | |
160 | + | |
161 | + # highlight search terms for the record with the given id. | |
162 | + def highlight(id, class_name, query, options = {}) | |
163 | + options.reverse_merge! :num_excerpts => 2, :pre_tag => '<em>', :post_tag => '</em>' | |
164 | + highlights = [] | |
165 | + ferret_index.synchronize do | |
166 | + doc_num = document_number(id, class_name) | |
167 | + if options[:field] | |
168 | + highlights << ferret_index.highlight(query, doc_num, options) | |
169 | + else | |
170 | + query = process_query(query) # process only once | |
171 | + aaf_configuration[:ferret_fields].each_pair do |field, config| | |
172 | + next if config[:store] == :no || config[:highlight] == :no | |
173 | + options[:field] = field | |
174 | + highlights << ferret_index.highlight(query, doc_num, options) | |
175 | + end | |
176 | + end | |
177 | + end | |
178 | + return highlights.compact.flatten[0..options[:num_excerpts]-1] | |
179 | + end | |
180 | + | |
181 | + # retrieves the ferret document number of the record with the given id. | |
182 | + def document_number(id, class_name) | |
183 | + hits = ferret_index.search(query_for_record(id, class_name)) | |
184 | + return hits.hits.first.doc if hits.total_hits == 1 | |
185 | + raise "cannot determine document number from primary key: #{id}" | |
186 | + end | |
187 | + | |
188 | + # build a ferret query matching only the record with the given id | |
189 | + # the class name only needs to be given in case of a shared index configuration | |
190 | + def query_for_record(id, class_name = nil) | |
191 | + Ferret::Search::TermQuery.new(:id, id.to_s) | |
192 | + end | |
193 | + | |
194 | + | |
195 | + protected | |
196 | + | |
197 | + # returns a MultiIndex instance operating on a MultiReader | |
198 | + def multi_index(model_classes) | |
199 | + model_classes.map!(&:constantize) if String === model_classes.first | |
200 | + model_classes.sort! { |a, b| a.name <=> b.name } | |
201 | + key = model_classes.inject("") { |s, clazz| s + clazz.name } | |
202 | + multi_config = aaf_configuration[:ferret].dup | |
203 | + multi_config.delete :default_field # we don't want the default field list of *this* class for multi_searching | |
204 | + ActsAsFerret::multi_indexes[key] ||= MultiIndex.new(model_classes, multi_config) | |
205 | + end | |
206 | + | |
207 | + end | |
208 | + | |
209 | +end | ... | ... |
... | ... | @@ -0,0 +1,217 @@ |
1 | +module ActsAsFerret #:nodoc: | |
2 | + | |
3 | + module MoreLikeThis | |
4 | + | |
5 | + module InstanceMethods | |
6 | + | |
7 | + # returns other instances of this class, which have similar contents | |
8 | + # like this one. Basically works like this: find out n most interesting | |
9 | + # (i.e. characteristic) terms from this document, and then build a | |
10 | + # query from those which is run against the whole index. Which terms | |
11 | + # are interesting is decided on variour criteria which can be | |
12 | + # influenced by the given options. | |
13 | + # | |
14 | + # The algorithm used here is a quite straight port of the MoreLikeThis class | |
15 | + # from Apache Lucene. | |
16 | + # | |
17 | + # options are: | |
18 | + # :field_names : Array of field names to use for similarity search (mandatory) | |
19 | + # :min_term_freq => 2, # Ignore terms with less than this frequency in the source doc. | |
20 | + # :min_doc_freq => 5, # Ignore words which do not occur in at least this many docs | |
21 | + # :min_word_length => nil, # Ignore words shorter than this length (longer words tend to | |
22 | + # be more characteristic for the document they occur in). | |
23 | + # :max_word_length => nil, # Ignore words if greater than this len. | |
24 | + # :max_query_terms => 25, # maximum number of terms in the query built | |
25 | + # :max_num_tokens => 5000, # maximum number of tokens to examine in a single field | |
26 | + # :boost => false, # when true, a boost according to the relative score of | |
27 | + # a term is applied to this Term's TermQuery. | |
28 | + # :similarity => 'DefaultAAFSimilarity' # the similarity implementation to use (the default | |
29 | + # equals Ferret's internal similarity implementation) | |
30 | + # :analyzer => 'Ferret::Analysis::StandardAnalyzer' # class name of the analyzer to use | |
31 | + # :append_to_query => nil # proc taking a query object as argument, which will be called after generating the query. can be used to further manipulate the query used to find related documents, i.e. to constrain the search to a given class in single table inheritance scenarios | |
32 | + # ferret_options : Ferret options handed over to find_by_contents (i.e. for limits and sorting) | |
33 | + # ar_options : options handed over to find_by_contents for AR scoping | |
34 | + def more_like_this(options = {}, ferret_options = {}, ar_options = {}) | |
35 | + options = { | |
36 | + :field_names => nil, # Default field names | |
37 | + :min_term_freq => 2, # Ignore terms with less than this frequency in the source doc. | |
38 | + :min_doc_freq => 5, # Ignore words which do not occur in at least this many docs | |
39 | + :min_word_length => 0, # Ignore words if less than this len. Default is not to ignore any words. | |
40 | + :max_word_length => 0, # Ignore words if greater than this len. Default is not to ignore any words. | |
41 | + :max_query_terms => 25, # maximum number of terms in the query built | |
42 | + :max_num_tokens => 5000, # maximum number of tokens to analyze when analyzing contents | |
43 | + :boost => false, | |
44 | + :similarity => 'ActsAsFerret::MoreLikeThis::DefaultAAFSimilarity', # class name of the similarity implementation to use | |
45 | + :analyzer => 'Ferret::Analysis::StandardAnalyzer', # class name of the analyzer to use | |
46 | + :append_to_query => nil, | |
47 | + :base_class => self.class # base class to use for querying, useful in STI scenarios where BaseClass.find_by_contents can be used to retrieve results from other classes, too | |
48 | + }.update(options) | |
49 | + #index.search_each('id:*') do |doc, score| | |
50 | + # puts "#{doc} == #{index[doc][:description]}" | |
51 | + #end | |
52 | + clazz = options[:base_class] | |
53 | + options[:base_class] = clazz.name | |
54 | + query = clazz.aaf_index.build_more_like_this_query(self.id, self.class.name, options) | |
55 | + options[:append_to_query].call(query) if options[:append_to_query] | |
56 | + clazz.find_by_contents(query, ferret_options, ar_options) | |
57 | + end | |
58 | + | |
59 | + end | |
60 | + | |
61 | + module IndexMethods | |
62 | + | |
63 | + # TODO to allow morelikethis for unsaved records, we have to give the | |
64 | + # unsaved record's data to this method. check how this will work out | |
65 | + # via drb... | |
66 | + def build_more_like_this_query(id, class_name, options) | |
67 | + [:similarity, :analyzer].each { |sym| options[sym] = options[sym].constantize.new } | |
68 | + ferret_index.synchronize do # avoid that concurrent writes close our reader | |
69 | + ferret_index.send(:ensure_reader_open) | |
70 | + reader = ferret_index.send(:reader) | |
71 | + term_freq_map = retrieve_terms(id, class_name, reader, options) | |
72 | + priority_queue = create_queue(term_freq_map, reader, options) | |
73 | + create_query(id, class_name, priority_queue, options) | |
74 | + end | |
75 | + end | |
76 | + | |
77 | + protected | |
78 | + | |
79 | + def create_query(id, class_name, priority_queue, options={}) | |
80 | + query = Ferret::Search::BooleanQuery.new | |
81 | + qterms = 0 | |
82 | + best_score = nil | |
83 | + while(cur = priority_queue.pop) | |
84 | + term_query = Ferret::Search::TermQuery.new(cur.field, cur.word) | |
85 | + | |
86 | + if options[:boost] | |
87 | + # boost term according to relative score | |
88 | + # TODO untested | |
89 | + best_score ||= cur.score | |
90 | + term_query.boost = cur.score / best_score | |
91 | + end | |
92 | + begin | |
93 | + query.add_query(term_query, :should) | |
94 | + rescue Ferret::Search::BooleanQuery::TooManyClauses | |
95 | + break | |
96 | + end | |
97 | + qterms += 1 | |
98 | + break if options[:max_query_terms] > 0 && qterms >= options[:max_query_terms] | |
99 | + end | |
100 | + # exclude the original record | |
101 | + query.add_query(query_for_record(id, class_name), :must_not) | |
102 | + return query | |
103 | + end | |
104 | + | |
105 | + | |
106 | + | |
107 | + # creates a term/term_frequency map for terms from the fields | |
108 | + # given in options[:field_names] | |
109 | + def retrieve_terms(id, class_name, reader, options) | |
110 | + raise "more_like_this atm only works on saved records" if id.nil? | |
111 | + document_number = document_number(id, class_name) rescue nil | |
112 | + field_names = options[:field_names] | |
113 | + max_num_tokens = options[:max_num_tokens] | |
114 | + term_freq_map = Hash.new(0) | |
115 | + doc = nil | |
116 | + record = nil | |
117 | + field_names.each do |field| | |
118 | + #puts "field: #{field}" | |
119 | + term_freq_vector = reader.term_vector(document_number, field) if document_number | |
120 | + #if false | |
121 | + if term_freq_vector | |
122 | + # use stored term vector | |
123 | + # puts 'using stored term vector' | |
124 | + term_freq_vector.terms.each do |term| | |
125 | + term_freq_map[term.text] += term.positions.size unless noise_word?(term.text, options) | |
126 | + end | |
127 | + else | |
128 | + # puts 'no stored term vector' | |
129 | + # no term vector stored, but we have stored the contents in the index | |
130 | + # -> extract terms from there | |
131 | + content = nil | |
132 | + if document_number | |
133 | + doc = reader[document_number] | |
134 | + content = doc[field] | |
135 | + end | |
136 | + unless content | |
137 | + # no term vector, no stored content, so try content from this instance | |
138 | + record ||= options[:base_class].constantize.find(id) | |
139 | + content = record.content_for_field_name(field.to_s) | |
140 | + end | |
141 | + puts "have doc: #{doc[:id]} with #{field} == #{content}" | |
142 | + token_count = 0 | |
143 | + | |
144 | + ts = options[:analyzer].token_stream(field, content) | |
145 | + while token = ts.next | |
146 | + break if (token_count+=1) > max_num_tokens | |
147 | + next if noise_word?(token.text, options) | |
148 | + term_freq_map[token.text] += 1 | |
149 | + end | |
150 | + end | |
151 | + end | |
152 | + term_freq_map | |
153 | + end | |
154 | + | |
155 | + # create an ordered(by score) list of word,fieldname,score | |
156 | + # structures | |
157 | + def create_queue(term_freq_map, reader, options) | |
158 | + pq = Array.new(term_freq_map.size) | |
159 | + | |
160 | + similarity = options[:similarity] | |
161 | + num_docs = reader.num_docs | |
162 | + term_freq_map.each_pair do |word, tf| | |
163 | + # filter out words that don't occur enough times in the source | |
164 | + next if options[:min_term_freq] && tf < options[:min_term_freq] | |
165 | + | |
166 | + # go through all the fields and find the largest document frequency | |
167 | + top_field = options[:field_names].first | |
168 | + doc_freq = 0 | |
169 | + options[:field_names].each do |field_name| | |
170 | + freq = reader.doc_freq(field_name, word) | |
171 | + if freq > doc_freq | |
172 | + top_field = field_name | |
173 | + doc_freq = freq | |
174 | + end | |
175 | + end | |
176 | + # filter out words that don't occur in enough docs | |
177 | + next if options[:min_doc_freq] && doc_freq < options[:min_doc_freq] | |
178 | + next if doc_freq == 0 # index update problem ? | |
179 | + | |
180 | + idf = similarity.idf(doc_freq, num_docs) | |
181 | + score = tf * idf | |
182 | + pq << FrequencyQueueItem.new(word, top_field, score) | |
183 | + end | |
184 | + pq.compact! | |
185 | + pq.sort! { |a,b| a.score<=>b.score } | |
186 | + return pq | |
187 | + end | |
188 | + | |
189 | + def noise_word?(text, options) | |
190 | + len = text.length | |
191 | + ( | |
192 | + (options[:min_word_length] > 0 && len < options[:min_word_length]) || | |
193 | + (options[:max_word_length] > 0 && len > options[:max_word_length]) || | |
194 | + (options[:stop_words] && options.include?(text)) | |
195 | + ) | |
196 | + end | |
197 | + | |
198 | + end | |
199 | + | |
200 | + class DefaultAAFSimilarity | |
201 | + def idf(doc_freq, num_docs) | |
202 | + return 0.0 if num_docs == 0 | |
203 | + return Math.log(num_docs.to_f/(doc_freq+1)) + 1.0 | |
204 | + end | |
205 | + end | |
206 | + | |
207 | + | |
208 | + class FrequencyQueueItem | |
209 | + attr_reader :word, :field, :score | |
210 | + def initialize(word, field, score) | |
211 | + @word = word; @field = field; @score = score | |
212 | + end | |
213 | + end | |
214 | + | |
215 | + end | |
216 | +end | |
217 | + | ... | ... |
... | ... | @@ -0,0 +1,83 @@ |
1 | +module ActsAsFerret #:nodoc: | |
2 | + | |
3 | + # this class is not threadsafe | |
4 | + class MultiIndex | |
5 | + | |
6 | + def initialize(model_classes, options = {}) | |
7 | + @model_classes = model_classes | |
8 | + # ensure all models indexes exist | |
9 | + @model_classes.each { |m| m.aaf_index.ensure_index_exists } | |
10 | + default_fields = @model_classes.inject([]) do |fields, c| | |
11 | + fields + [ c.aaf_configuration[:ferret][:default_field] ].flatten | |
12 | + end | |
13 | + @options = { | |
14 | + :default_field => default_fields | |
15 | + }.update(options) | |
16 | + end | |
17 | + | |
18 | + def search(query, options={}) | |
19 | + #puts "querystring: #{query.to_s}" | |
20 | + query = process_query(query) | |
21 | + #puts "parsed query: #{query.to_s}" | |
22 | + searcher.search(query, options) | |
23 | + end | |
24 | + | |
25 | + def search_each(query, options = {}, &block) | |
26 | + query = process_query(query) | |
27 | + searcher.search_each(query, options, &block) | |
28 | + end | |
29 | + | |
30 | + # checks if all our sub-searchers still are up to date | |
31 | + def latest? | |
32 | + return false unless @reader | |
33 | + # segfaults with 0.10.4 --> TODO report as bug @reader.latest? | |
34 | + @sub_readers.each do |r| | |
35 | + return false unless r.latest? | |
36 | + end | |
37 | + true | |
38 | + end | |
39 | + | |
40 | + def searcher | |
41 | + ensure_searcher | |
42 | + @searcher | |
43 | + end | |
44 | + | |
45 | + def doc(i) | |
46 | + searcher[i] | |
47 | + end | |
48 | + alias :[] :doc | |
49 | + | |
50 | + def query_parser | |
51 | + @query_parser ||= Ferret::QueryParser.new(@options) | |
52 | + end | |
53 | + | |
54 | + def process_query(query) | |
55 | + query = query_parser.parse(query) if query.is_a?(String) | |
56 | + return query | |
57 | + end | |
58 | + | |
59 | + def close | |
60 | + @searcher.close if @searcher | |
61 | + @reader.close if @reader | |
62 | + end | |
63 | + | |
64 | + protected | |
65 | + | |
66 | + def ensure_searcher | |
67 | + unless latest? | |
68 | + @sub_readers = @model_classes.map { |clazz| | |
69 | + begin | |
70 | + reader = Ferret::Index::IndexReader.new(clazz.aaf_configuration[:index_dir]) | |
71 | + rescue Exception | |
72 | + raise "error opening #{clazz.aaf_configuration[:index_dir]}: #{$!}" | |
73 | + end | |
74 | + } | |
75 | + close | |
76 | + @reader = Ferret::Index::IndexReader.new(@sub_readers) | |
77 | + @searcher = Ferret::Search::Searcher.new(@reader) | |
78 | + end | |
79 | + end | |
80 | + | |
81 | + end # of class MultiIndex | |
82 | + | |
83 | +end | ... | ... |
... | ... | @@ -0,0 +1,50 @@ |
1 | +require 'drb' | |
2 | +module ActsAsFerret | |
3 | + | |
4 | + # This index implementation connects to a remote ferret server instance. It | |
5 | + # basically forwards all calls to the remote server. | |
6 | + class RemoteIndex < AbstractIndex | |
7 | + | |
8 | + def initialize(config) | |
9 | + @config = config | |
10 | + @ferret_config = config[:ferret] | |
11 | + @server = DRbObject.new(nil, config[:remote]) | |
12 | + end | |
13 | + | |
14 | + def method_missing(method_name, *args) | |
15 | + args.unshift model_class_name | |
16 | + @server.send(method_name, *args) | |
17 | + end | |
18 | + | |
19 | + def find_id_by_contents(q, options = {}, &proc) | |
20 | + total_hits, results = @server.find_id_by_contents(model_class_name, q, options) | |
21 | + block_given? ? yield_results(total_hits, results, &proc) : [ total_hits, results ] | |
22 | + end | |
23 | + | |
24 | + def id_multi_search(query, models, options, &proc) | |
25 | + total_hits, results = @server.id_multi_search(model_class_name, query, models, options) | |
26 | + block_given? ? yield_results(total_hits, results, &proc) : [ total_hits, results ] | |
27 | + end | |
28 | + | |
29 | + # add record to index | |
30 | + def add(record) | |
31 | + @server.add record.class.name, record.to_doc | |
32 | + end | |
33 | + alias << add | |
34 | + | |
35 | + private | |
36 | + | |
37 | + def yield_results(total_hits, results) | |
38 | + results.each do |result| | |
39 | + yield result[:model], result[:id], result[:score], result[:data] | |
40 | + end | |
41 | + total_hits | |
42 | + end | |
43 | + | |
44 | + def model_class_name | |
45 | + @config[:class_name] | |
46 | + end | |
47 | + | |
48 | + end | |
49 | + | |
50 | +end | ... | ... |
... | ... | @@ -0,0 +1,14 @@ |
1 | +module ActsAsFerret | |
2 | + | |
3 | + class SharedIndex < LocalIndex | |
4 | + | |
5 | + # build a ferret query matching only the record with the given id and class | |
6 | + def query_for_record(id, class_name) | |
7 | + returning bq = Ferret::Search::BooleanQuery.new do | |
8 | + bq.add_query(Ferret::Search::TermQuery.new(:id, id.to_s), :must) | |
9 | + bq.add_query(Ferret::Search::TermQuery.new(:class_name, class_name), :must) | |
10 | + end | |
11 | + end | |
12 | + | |
13 | + end | |
14 | +end | ... | ... |
vendor/plugins/acts_as_ferret/lib/shared_index_class_methods.rb
0 → 100644
... | ... | @@ -0,0 +1,90 @@ |
1 | +module ActsAsFerret | |
2 | + | |
3 | + # class methods for classes using acts_as_ferret :single_index => true | |
4 | + module SharedIndexClassMethods | |
5 | + | |
6 | + def find_id_by_contents(q, options = {}, &block) | |
7 | + # add class name scoping to query if necessary | |
8 | + unless options[:models] == :all # search needs to be restricted by one or more class names | |
9 | + options[:models] ||= [] | |
10 | + # add this class to the list of given models | |
11 | + options[:models] << self unless options[:models].include?(self) | |
12 | + # keep original query | |
13 | + original_query = q | |
14 | + | |
15 | + if original_query.is_a? String | |
16 | + model_query = options[:models].map(&:name).join '|' | |
17 | + q << %{ +class_name:"#{model_query}"} | |
18 | + else | |
19 | + q = Ferret::Search::BooleanQuery.new | |
20 | + q.add_query(original_query, :must) | |
21 | + model_query = Ferret::Search::BooleanQuery.new | |
22 | + options[:models].each do |model| | |
23 | + model_query.add_query(Ferret::Search::TermQuery.new(:class_name, model.name), :should) | |
24 | + end | |
25 | + q.add_query(model_query, :must) | |
26 | + end | |
27 | + end | |
28 | + options.delete :models | |
29 | + | |
30 | + super(q, options, &block) | |
31 | + end | |
32 | + | |
33 | + # Overrides the standard find_by_contents for searching a shared index. | |
34 | + # | |
35 | + # please note that records from different models will be fetched in | |
36 | + # separate sql calls, so any sql order_by clause given with | |
37 | + # find_options[:order] will be ignored. | |
38 | + def find_by_contents(q, options = {}, find_options = {}) | |
39 | + if order = find_options.delete(:order) | |
40 | + logger.warn "using a shared index, so ignoring order_by clause #{order}" | |
41 | + end | |
42 | + total_hits, result = find_records_lazy_or_not q, options, find_options | |
43 | + # sort so results have the same order they had when originally retrieved | |
44 | + # from ferret | |
45 | + return SearchResults.new(result, total_hits) | |
46 | + end | |
47 | + | |
48 | + protected | |
49 | + | |
50 | + def ar_find_by_contents(q, options = {}, find_options = {}) | |
51 | + total_hits, id_arrays = collect_results(q, options) | |
52 | + result = retrieve_records(id_arrays, find_options) | |
53 | + result.sort! { |a, b| id_arrays[a.class.name][a.id.to_s].first <=> id_arrays[b.class.name][b.id.to_s].first } | |
54 | + [ total_hits, result ] | |
55 | + end | |
56 | + | |
57 | + def collect_results(q, options = {}) | |
58 | + id_arrays = {} | |
59 | + # get object ids for index hits | |
60 | + rank = 0 | |
61 | + total_hits = find_id_by_contents(q, options) do |model, id, score, data| | |
62 | + id_arrays[model] ||= {} | |
63 | + # store result rank and score | |
64 | + id_arrays[model][id] = [ rank += 1, score ] | |
65 | + end | |
66 | + [ total_hits, id_arrays ] | |
67 | + end | |
68 | + | |
69 | + | |
70 | + # determine all field names in the shared index | |
71 | + # TODO unused | |
72 | +# def single_index_field_names(models) | |
73 | +# @single_index_field_names ||= ( | |
74 | +# searcher = Ferret::Search::Searcher.new(class_index_dir) | |
75 | +# if searcher.reader.respond_to?(:get_field_names) | |
76 | +# (searcher.reader.send(:get_field_names) - ['id', 'class_name']).to_a | |
77 | +# else | |
78 | +# puts <<-END | |
79 | +#unable to retrieve field names for class #{self.name}, please | |
80 | +#consider naming all indexed fields in your call to acts_as_ferret! | |
81 | +# END | |
82 | +# models.map { |m| m.content_columns.map { |col| col.name } }.flatten | |
83 | +# end | |
84 | +# ) | |
85 | +# | |
86 | +# end | |
87 | + | |
88 | + end | |
89 | +end | |
90 | + | ... | ... |
... | ... | @@ -0,0 +1,131 @@ |
1 | +# rakefile for acts_as_ferret. | |
2 | +# use to create a gem or generate rdoc api documentation. | |
3 | +# | |
4 | +# RELEASE creation: | |
5 | +# rake release REL=x.y.z | |
6 | + | |
7 | +require 'rake' | |
8 | +require 'rake/rdoctask' | |
9 | +require 'rake/packagetask' | |
10 | +require 'rake/gempackagetask' | |
11 | +require 'rake/testtask' | |
12 | +require 'rake/contrib/rubyforgepublisher' | |
13 | + | |
14 | +def announce(msg='') | |
15 | + STDERR.puts msg | |
16 | +end | |
17 | + | |
18 | + | |
19 | +PKG_NAME = 'acts_as_ferret' | |
20 | +PKG_VERSION = ENV['REL'] | |
21 | +PKG_FILE_NAME = "#{PKG_NAME}-#{PKG_VERSION}" | |
22 | +RUBYFORGE_PROJECT = 'actsasferret' | |
23 | +RUBYFORGE_USER = 'jkraemer' | |
24 | + | |
25 | +desc 'Default: run unit tests.' | |
26 | +task :default => :test | |
27 | + | |
28 | +desc 'Test the acts_as_ferret plugin.' | |
29 | +Rake::TestTask.new(:test) do |t| | |
30 | + t.libs << 'lib' | |
31 | + t.pattern = 'test/**/*_test.rb' | |
32 | + t.verbose = true | |
33 | +end | |
34 | + | |
35 | +desc 'Generate documentation for the acts_as_ferret plugin.' | |
36 | +Rake::RDocTask.new(:rdoc) do |rdoc| | |
37 | + rdoc.rdoc_dir = 'html' | |
38 | + rdoc.title = "acts_as_ferret - Ferret based full text search for any ActiveRecord model" | |
39 | + rdoc.options << '--line-numbers' << '--inline-source' | |
40 | + rdoc.options << '--main' << 'README' | |
41 | + rdoc.rdoc_files.include('README', 'LICENSE') | |
42 | + rdoc.template = "#{ENV['template']}.rb" if ENV['template'] | |
43 | + rdoc.rdoc_files.include('lib/**/*.rb') | |
44 | +end | |
45 | + | |
46 | +desc "Publish the API documentation" | |
47 | +task :pdoc => [:rdoc] do | |
48 | + Rake::RubyForgePublisher.new(RUBYFORGE_PROJECT, RUBYFORGE_USER).upload | |
49 | +end | |
50 | + | |
51 | +if PKG_VERSION | |
52 | + spec = Gem::Specification.new do |s| | |
53 | + s.name = PKG_NAME | |
54 | + s.version = PKG_VERSION | |
55 | + s.platform = Gem::Platform::RUBY | |
56 | + s.summary = "acts_as_ferret - Ferret based full text search for any ActiveRecord model" | |
57 | + s.files = Dir.glob('**/*', File::FNM_DOTMATCH).reject do |f| | |
58 | + [ /\.$/, /sqlite$/, /\.log$/, /^pkg/, /\.svn/, /\.\w+\.sw.$/, | |
59 | + /^html/, /\~$/, /\/\._/, /\/#/ ].any? {|regex| f =~ regex } | |
60 | + end | |
61 | + #s.files = FileList["{lib,test}/**/*"].to_a + %w(README MIT-LICENSE CHANGELOG) | |
62 | + # s.files.delete ... | |
63 | + s.require_path = 'lib' | |
64 | + s.autorequire = 'acts_as_ferret' | |
65 | + s.has_rdoc = true | |
66 | + # s.test_files = Dir['test/**/*_test.rb'] | |
67 | + s.author = "Jens Kraemer" | |
68 | + s.email = "jk@jkraemer.net" | |
69 | + s.homepage = "http://projects.jkraemer.net/acts_as_ferret" | |
70 | + end | |
71 | + | |
72 | + package_task = Rake::GemPackageTask.new(spec) do |pkg| | |
73 | + pkg.need_tar = true | |
74 | + end | |
75 | + | |
76 | + # Validate that everything is ready to go for a release. | |
77 | + task :prerelease do | |
78 | + announce | |
79 | + announce "**************************************************************" | |
80 | + announce "* Making RubyGem Release #{PKG_VERSION}" | |
81 | + announce "**************************************************************" | |
82 | + announce | |
83 | + # Are all source files checked in? | |
84 | + if ENV['RELTEST'] | |
85 | + announce "Release Task Testing, skipping checked-in file test" | |
86 | + else | |
87 | + announce "Pulling in svn..." | |
88 | + `svk pull .` | |
89 | + announce "Checking for unchecked-in files..." | |
90 | + data = `svk st` | |
91 | + unless data =~ /^$/ | |
92 | + fail "SVK status is not clean ... do you have unchecked-in files?" | |
93 | + end | |
94 | + announce "No outstanding checkins found ... OK" | |
95 | + announce "Pushing to svn..." | |
96 | + `svk push .` | |
97 | + end | |
98 | + end | |
99 | + | |
100 | + | |
101 | + desc "tag the new release" | |
102 | + task :tag => [ :prerelease ] do | |
103 | + reltag = "REL_#{PKG_VERSION.gsub(/\./, '_')}" | |
104 | + reltag << ENV['REUSE'].gsub(/\./, '_') if ENV['REUSE'] | |
105 | + announce "Tagging with [#{PKG_VERSION}]" | |
106 | + if ENV['RELTEST'] | |
107 | + announce "Release Task Testing, skipping tagging" | |
108 | + else | |
109 | + `svn copy -m 'tagging version #{PKG_VERSION}' svn://projects.jkraemer.net/acts_as_ferret/trunk/plugin svn://projects.jkraemer.net/acts_as_ferret/tags/#{PKG_VERSION}` | |
110 | + `svn del -m 'remove old stable' svn://projects.jkraemer.net/acts_as_ferret/tags/stable` | |
111 | + `svn copy -m 'tagging version #{PKG_VERSION} as stable' svn://projects.jkraemer.net/acts_as_ferret/tags/#{PKG_VERSION} svn://projects.jkraemer.net/acts_as_ferret/tags/stable` | |
112 | + end | |
113 | + end | |
114 | + | |
115 | + # Upload release to rubyforge | |
116 | + desc "Upload release to rubyforge" | |
117 | + task :prel => [ :tag, :prerelease, :package ] do | |
118 | + `rubyforge login` | |
119 | + release_command = "rubyforge add_release #{RUBYFORGE_PROJECT} #{PKG_NAME} '#{PKG_VERSION}' pkg/#{PKG_NAME}-#{PKG_VERSION}.gem" | |
120 | + puts release_command | |
121 | + system(release_command) | |
122 | + `rubyforge config #{RUBYFORGE_PROJECT}` | |
123 | + release_command = "rubyforge add_file #{RUBYFORGE_PROJECT} #{PKG_NAME} '#{PKG_VERSION}' pkg/#{PKG_NAME}-#{PKG_VERSION}.tgz" | |
124 | + puts release_command | |
125 | + system(release_command) | |
126 | + end | |
127 | + | |
128 | + desc 'Publish the gem and API docs' | |
129 | + task :release => [:pdoc, :prel ] | |
130 | + | |
131 | +end | ... | ... |
... | ... | @@ -0,0 +1,18 @@ |
1 | +#!/usr/bin/env ruby | |
2 | + | |
3 | +# Ferret DRb server launcher script | |
4 | +# | |
5 | +# Place doc/ferret_server.yml into RAILS_ROOT/config and fit to taste. | |
6 | +# | |
7 | +# Start this script with script/runner and RAILS_ENV set. | |
8 | +# | |
9 | +# to run the unit tests against the drb server, start it with | |
10 | +# RAILS_ENV=test script/runner script/ferret_server | |
11 | +# and run your tests with the AAF_REMOTE environment variable set to a | |
12 | +# non-empty value | |
13 | + | |
14 | + | |
15 | +ActsAsFerret::Remote::Server.start | |
16 | +DRb.thread.join | |
17 | + | |
18 | + | ... | ... |
... | ... | @@ -0,0 +1,72 @@ |
1 | +#!/usr/bin/env ruby | |
2 | +# Ferret DRb server launcher script | |
3 | +# | |
4 | +# Place doc/ferret_server.yml into RAILS_ROOT/config and fit to taste. Start | |
5 | +# it with RAILS_ENV set to the desired environment. | |
6 | +# | |
7 | +# | |
8 | +# To run the demo project's unit tests against the drb server, start it with | |
9 | +# | |
10 | +# RAILS_ENV=test script/ferret_start | |
11 | +# | |
12 | +# and run your tests with the AAF_REMOTE environment variable set to a | |
13 | +# non-empty value: | |
14 | +# | |
15 | +# AAF_REMOTE=true rake | |
16 | +# | |
17 | +# The server writes a log file in log/ferret_server.log, it's | |
18 | +# STDOUT gets redirected to log/ferret_server.out | |
19 | + | |
20 | +ENV['FERRET_USE_LOCAL_INDEX'] = 'true' | |
21 | +require File.dirname(__FILE__) + '/../config/boot' | |
22 | +require RAILS_ROOT + '/config/environment' | |
23 | + | |
24 | + | |
25 | +config = ActsAsFerret::Remote::Config.load | |
26 | +@pid_file = config['pid_file'] | |
27 | + | |
28 | +def write_pid_file | |
29 | + raise "No PID file defined" if @pid_file.blank? | |
30 | + open(@pid_file,"w") {|f| f.write(Process.pid) } | |
31 | +end | |
32 | + | |
33 | +def safefork | |
34 | + tryagain = true | |
35 | + | |
36 | + while tryagain | |
37 | + tryagain = false | |
38 | + begin | |
39 | + if pid = fork | |
40 | + return pid | |
41 | + end | |
42 | + rescue Errno::EWOULDBLOCK | |
43 | + sleep 5 | |
44 | + tryagain = true | |
45 | + end | |
46 | + end | |
47 | +end | |
48 | + | |
49 | +safefork and exit | |
50 | +at_exit do | |
51 | + File.unlink(@pid_file) if @pid_file && File.exists?(@pid_file) && File.read(@pid_file).to_i == Process.pid | |
52 | +end | |
53 | +print "Starting ferret DRb server..." | |
54 | +trap("TERM") { exit(0) } | |
55 | +sess_id = Process.setsid | |
56 | + | |
57 | + | |
58 | +begin | |
59 | + ActsAsFerret::Remote::Server.start | |
60 | + write_pid_file | |
61 | + puts "Done." | |
62 | + STDIN.reopen "/dev/null" # Free file descriptors and | |
63 | + STDOUT.reopen "#{RAILS_ROOT}/log/ferret_server.out", "a" # point them somewhere sensible | |
64 | + STDERR.reopen STDOUT # STDOUT/STDERR should go to a logfile | |
65 | +rescue | |
66 | + $stderr.puts "Error starting ferret DRb server: #{$!}" | |
67 | + $stderr.puts $!.backtrace | |
68 | + exit(1) | |
69 | +end | |
70 | +DRb.thread.join | |
71 | + | |
72 | +# vim:set filetype=ruby: | ... | ... |
... | ... | @@ -0,0 +1,26 @@ |
1 | +#!/usr/bin/env script/runner | |
2 | + | |
3 | +config = ActsAsFerret::Remote::Config.load | |
4 | + | |
5 | +def send_signal(signal, pid_file) | |
6 | + pid = open(pid_file).read.to_i | |
7 | + print "Sending #{signal} to ferret_server with PID #{pid}..." | |
8 | + begin | |
9 | + Process.kill(signal, pid) | |
10 | + rescue Errno::ESRCH | |
11 | + puts "Process does not exist. Not running. Removing stale pid file anyway." | |
12 | + File.unlink(pid_file) | |
13 | + end | |
14 | + | |
15 | + puts "Done." | |
16 | +end | |
17 | + | |
18 | +pid_file = config['pid_file'] | |
19 | +puts "Stopping ferret_server..." | |
20 | +if File.file?(pid_file) | |
21 | + send_signal("TERM", pid_file) | |
22 | +else | |
23 | + puts "no pid file found" | |
24 | +end | |
25 | + | |
26 | +# vim:set filetype=ruby: | ... | ... |
... | ... | @@ -0,0 +1,58 @@ |
1 | +[1 July 2007] | |
2 | + | |
3 | +* Fix incorrect tagging when the case of the tag list is changed. | |
4 | + | |
5 | +* Fix deprecated Tag.delimiter accessor. | |
6 | + | |
7 | +[23 June 2007] | |
8 | + | |
9 | +* Add validation to Tag model. | |
10 | + | |
11 | +* find_options_for_tagged_with should always return a hash. | |
12 | + | |
13 | +* find_tagged_with passing in no tags should return an empty array. | |
14 | + | |
15 | +* Improve compatibility with PostgreSQL. | |
16 | + | |
17 | +[21 June 2007] | |
18 | + | |
19 | +* Remove extra .rb from generated migration file name. | |
20 | + | |
21 | +[15 June 2007] | |
22 | + | |
23 | +* Introduce TagList class. | |
24 | + | |
25 | +* Various cleanups and improvements. | |
26 | + | |
27 | +* Use TagList.delimiter now, not Tag.delimiter. Tag.delimiter will be removed at some stage. | |
28 | + | |
29 | +[11 June 2007] | |
30 | + | |
31 | +* Restructure the creation of the options for find_tagged_with [Thijs Cadier] | |
32 | + | |
33 | +* Add an example migration with a generator. | |
34 | + | |
35 | +* Add caching. | |
36 | + | |
37 | +* Fix compatibility with Ruby < 1.8.6 | |
38 | + | |
39 | +[23 April 2007] | |
40 | + | |
41 | +* Make tag_list to respect Tag.delimiter | |
42 | + | |
43 | +[31 March 2007] | |
44 | + | |
45 | +* Add Tag.delimiter accessor to change how tags are parsed. | |
46 | +* Fix :include => :tags when used with find_tagged_with | |
47 | + | |
48 | +[7 March 2007] | |
49 | + | |
50 | +* Fix tag_counts for SQLServer [Brad Young] | |
51 | + | |
52 | +[21 Feb 2007] | |
53 | + | |
54 | +* Use scoping instead of TagCountsExtension [Michael Schuerig] | |
55 | + | |
56 | +[7 Jan 2007] | |
57 | + | |
58 | +* Add :match_all to find_tagged_with [Michael Sheakoski] | ... | ... |
... | ... | @@ -0,0 +1,20 @@ |
1 | +Copyright (c) 2006 Jonathan Viney | |
2 | + | |
3 | +Permission is hereby granted, free of charge, to any person obtaining | |
4 | +a copy of this software and associated documentation files (the | |
5 | +"Software"), to deal in the Software without restriction, including | |
6 | +without limitation the rights to use, copy, modify, merge, publish, | |
7 | +distribute, sublicense, and/or sell copies of the Software, and to | |
8 | +permit persons to whom the Software is furnished to do so, subject to | |
9 | +the following conditions: | |
10 | + | |
11 | +The above copyright notice and this permission notice shall be | |
12 | +included in all copies or substantial portions of the Software. | |
13 | + | |
14 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
15 | +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
16 | +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |
17 | +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | |
18 | +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | |
19 | +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | |
20 | +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | ... | ... |
... | ... | @@ -0,0 +1,116 @@ |
1 | += acts_as_taggable_on_steroids | |
2 | + | |
3 | +If you find this plugin useful, please consider a donation to show your support! | |
4 | + | |
5 | + http://www.paypal.com/cgi-bin/webscr?cmd=_send-money | |
6 | + | |
7 | + Email address: jonathan.viney@gmail.com | |
8 | + | |
9 | +== Instructions | |
10 | + | |
11 | +This plugin is based on acts_as_taggable by DHH but includes extras | |
12 | +such as tests, smarter tag assignment, and tag cloud calculations. | |
13 | + | |
14 | +Thanks to www.fanacious.com for allowing this plugin to be released. Please check out | |
15 | +their site to show your support. | |
16 | + | |
17 | +== Resources | |
18 | + | |
19 | +Install | |
20 | + * script/plugin install http://svn.viney.net.nz/things/rails/plugins/acts_as_taggable_on_steroids | |
21 | + | |
22 | +== Usage | |
23 | + | |
24 | +=== Prepare database | |
25 | + | |
26 | +Generate and apply the migration: | |
27 | + | |
28 | + ruby script/generate acts_as_taggable_migration | |
29 | + rake db:migrate | |
30 | + | |
31 | +=== Basic tagging | |
32 | + | |
33 | +Using the examples from the tests, let's suppose we have users that have many posts and we want those | |
34 | +posts to be able to be tagged by the user. | |
35 | + | |
36 | +As usual, we add +acts_as_taggable+ to the Post class: | |
37 | + | |
38 | + class Post < ActiveRecord::Base | |
39 | + acts_as_taggable | |
40 | + | |
41 | + belongs_to :user | |
42 | + end | |
43 | + | |
44 | +We can now use the tagging methods provided by acts_as_taggable, <tt>tag_list</tt> and <tt>tag_list=</tt>. Both these | |
45 | +methods work like regular attribute accessors. | |
46 | + | |
47 | + p = Post.find(:first) | |
48 | + p.tag_list.to_s # "" | |
49 | + p.tag_list = "Funny, Silly" | |
50 | + p.save | |
51 | + p.reload.tag_list.to_s # "Funny, Silly" | |
52 | + | |
53 | +You can also add or remove arrays of tags. | |
54 | + | |
55 | + p.tag_list.add("Great", "Awful") | |
56 | + p.tag_list.remove("Funny") | |
57 | + | |
58 | +=== Finding tagged objects | |
59 | + | |
60 | +To retrieve objects tagged with a certain tag, use find_tagged_with. | |
61 | + | |
62 | + Post.find_tagged_with('Funny, Silly') | |
63 | + | |
64 | +By default, find_tagged_with will find objects that have any of the given tags. To | |
65 | +find only objects that are tagged with all the given tags, use match_all. | |
66 | + | |
67 | + Post.find_tagged_with('Funny, Silly', :match_all => true) | |
68 | + | |
69 | +=== Tag cloud calculations | |
70 | + | |
71 | +To construct tag clouds, the frequency of each tag needs to be calculated. | |
72 | +Because we specified +acts_as_taggable+ on the <tt>Post</tt> class, we can | |
73 | +get a calculation of all the tag counts by using <tt>Post.tag_counts</tt>. But what if we wanted a tag count for | |
74 | +an single user's posts? To achieve this we call tag_counts on the association: | |
75 | + | |
76 | + User.find(:first).posts.tag_counts | |
77 | + | |
78 | +=== Caching | |
79 | + | |
80 | +It is useful to cache the list of tags to reduce the number of queries executed. To do this, | |
81 | +add a column named <tt>cached_tag_list</tt> to the model which is being tagged. | |
82 | + | |
83 | + class CachePostTagList < ActiveRecord::Migration | |
84 | + def self.up | |
85 | + # You should make sure that the column is long enough to hold | |
86 | + # the full tag list. In some situations the :text type may be more appropriate. | |
87 | + add_column :posts, :cached_tag_list, :string | |
88 | + end | |
89 | + end | |
90 | + | |
91 | + class Post < ActiveRecord::Base | |
92 | + acts_as_taggable | |
93 | + | |
94 | + # The caching column defaults to cached_tag_list, but can be changed: | |
95 | + # | |
96 | + # set_cached_tag_list_column_name "my_caching_column_name" | |
97 | + end | |
98 | + | |
99 | +The details of the caching are handled for you. Just continue to use the tag_list accessor as you normally would. | |
100 | +Note that the cached tag list will not be updated if you directly create Tagging objects or manually append to the | |
101 | +<tt>tags</tt> or <tt>taggings</tt> associations. To update the cached tag list you should call <tt>save_cached_tag_list</tt> manually. | |
102 | + | |
103 | +=== Delimiter | |
104 | + | |
105 | +If you want to change the delimiter used to parse and present tags, set TagList.delimiter. | |
106 | +For example, to use spaces instead of commas, add the following to config/environment.rb: | |
107 | + | |
108 | + TagList.delimiter = " " | |
109 | + | |
110 | +=== Other | |
111 | + | |
112 | +Problems, comments, and suggestions all welcome. jonathan.viney@gmail.com | |
113 | + | |
114 | +== Credits | |
115 | + | |
116 | +www.fanacious.com | ... | ... |
... | ... | @@ -0,0 +1,22 @@ |
1 | +require 'rake' | |
2 | +require 'rake/testtask' | |
3 | +require 'rake/rdoctask' | |
4 | + | |
5 | +desc 'Default: run unit tests.' | |
6 | +task :default => :test | |
7 | + | |
8 | +desc 'Test the acts_as_taggable_on_steroids plugin.' | |
9 | +Rake::TestTask.new(:test) do |t| | |
10 | + t.libs << 'lib' | |
11 | + t.pattern = 'test/**/*_test.rb' | |
12 | + t.verbose = true | |
13 | +end | |
14 | + | |
15 | +desc 'Generate documentation for the acts_as_taggable_on_steroids plugin.' | |
16 | +Rake::RDocTask.new(:rdoc) do |rdoc| | |
17 | + rdoc.rdoc_dir = 'rdoc' | |
18 | + rdoc.title = 'Acts As Taggable On Steroids' | |
19 | + rdoc.options << '--line-numbers' << '--inline-source' | |
20 | + rdoc.rdoc_files.include('README') | |
21 | + rdoc.rdoc_files.include('lib/**/*.rb') | |
22 | +end | ... | ... |
vendor/plugins/acts_as_taggable_on_steroids/generators/acts_as_taggable_migration/acts_as_taggable_migration_generator.rb
0 → 100644
vendor/plugins/acts_as_taggable_on_steroids/generators/acts_as_taggable_migration/templates/migration.rb
0 → 100644
... | ... | @@ -0,0 +1,26 @@ |
1 | +class ActsAsTaggableMigration < ActiveRecord::Migration | |
2 | + def self.up | |
3 | + create_table :tags do |t| | |
4 | + t.column :name, :string | |
5 | + end | |
6 | + | |
7 | + create_table :taggings do |t| | |
8 | + t.column :tag_id, :integer | |
9 | + t.column :taggable_id, :integer | |
10 | + | |
11 | + # You should make sure that the column created is | |
12 | + # long enough to store the required class names. | |
13 | + t.column :taggable_type, :string | |
14 | + | |
15 | + t.column :created_at, :datetime | |
16 | + end | |
17 | + | |
18 | + add_index :taggings, :tag_id | |
19 | + add_index :taggings, [:taggable_id, :taggable_type] | |
20 | + end | |
21 | + | |
22 | + def self.down | |
23 | + drop_table :taggings | |
24 | + drop_table :tags | |
25 | + end | |
26 | +end | ... | ... |
vendor/plugins/acts_as_taggable_on_steroids/lib/acts_as_taggable.rb
0 → 100644
... | ... | @@ -0,0 +1,145 @@ |
1 | +module ActiveRecord | |
2 | + module Acts #:nodoc: | |
3 | + module Taggable #:nodoc: | |
4 | + def self.included(base) | |
5 | + base.extend(ClassMethods) | |
6 | + end | |
7 | + | |
8 | + module ClassMethods | |
9 | + def acts_as_taggable(options = {}) | |
10 | + has_many :taggings, :as => :taggable, :dependent => :destroy, :include => :tag | |
11 | + has_many :tags, :through => :taggings | |
12 | + | |
13 | + before_save :save_cached_tag_list | |
14 | + after_save :save_tags | |
15 | + | |
16 | + include ActiveRecord::Acts::Taggable::InstanceMethods | |
17 | + extend ActiveRecord::Acts::Taggable::SingletonMethods | |
18 | + | |
19 | + alias_method :reload_without_tag_list, :reload | |
20 | + alias_method :reload, :reload_with_tag_list | |
21 | + end | |
22 | + | |
23 | + def cached_tag_list_column_name | |
24 | + "cached_tag_list" | |
25 | + end | |
26 | + | |
27 | + def set_cached_tag_list_column_name(value = nil, &block) | |
28 | + define_attr_method :cached_tag_list_column_name, value, &block | |
29 | + end | |
30 | + end | |
31 | + | |
32 | + module SingletonMethods | |
33 | + # Pass either a tag string, or an array of strings or tags | |
34 | + # | |
35 | + # Options: | |
36 | + # :exclude - Find models that are not tagged with the given tags | |
37 | + # :match_all - Find models that match all of the given tags, not just one | |
38 | + # :conditions - A piece of SQL conditions to add to the query | |
39 | + def find_tagged_with(tags, options = {}) | |
40 | + tags = TagList.from(tags).names | |
41 | + return [] if tags.empty? | |
42 | + | |
43 | + conditions = tags.map {|t| sanitize_sql(["tags.name LIKE ?",t])}.join(' OR ') | |
44 | + conditions += ' AND ' + sanitize_sql(options.delete(:conditions)) if options[:conditions] | |
45 | + group = "#{table_name}.id HAVING COUNT(#{table_name}.id) = #{tags.size}" if options.delete(:match_all) | |
46 | + exclude = options.delete(:exclude) | |
47 | + taggeds = find(:all, {:conditions => conditions, :include => :tags, :group => group}.update(options)) | |
48 | + exclude ? find(:all) - taggeds : taggeds | |
49 | + end | |
50 | + | |
51 | + # Options: | |
52 | + # :start_at - Restrict the tags to those created after a certain time | |
53 | + # :end_at - Restrict the tags to those created before a certain time | |
54 | + # :conditions - A piece of SQL conditions to add to the query | |
55 | + # :limit - The maximum number of tags to return | |
56 | + # :order - A piece of SQL to order by. Eg 'tags.count desc' or 'taggings.created_at desc' | |
57 | + # :at_least - Exclude tags with a frequency less than the given value | |
58 | + # :at_most - Exclude tags with a frequency greater then the given value | |
59 | + def tag_counts(*args) | |
60 | + Tag.find(:all, find_options_for_tag_counts(*args)) | |
61 | + end | |
62 | + | |
63 | + def find_options_for_tag_counts(options = {}) | |
64 | + options.assert_valid_keys :start_at, :end_at, :conditions, :at_least, :at_most, :order, :limit | |
65 | + | |
66 | + scope = scope(:find) | |
67 | + start_at = sanitize_sql(["#{Tagging.table_name}.created_at >= ?", options[:start_at]]) if options[:start_at] | |
68 | + end_at = sanitize_sql(["#{Tagging.table_name}.created_at <= ?", options[:end_at]]) if options[:end_at] | |
69 | + | |
70 | + conditions = [ | |
71 | + "#{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)}", | |
72 | + options[:conditions], | |
73 | + scope && scope[:conditions], | |
74 | + start_at, | |
75 | + end_at | |
76 | + ] | |
77 | + conditions = conditions.compact.join(' and ') | |
78 | + | |
79 | + at_least = sanitize_sql(['COUNT(*) >= ?', options[:at_least]]) if options[:at_least] | |
80 | + at_most = sanitize_sql(['COUNT(*) <= ?', options[:at_most]]) if options[:at_most] | |
81 | + having = [at_least, at_most].compact.join(' and ') | |
82 | + group_by = "#{Tag.table_name}.id, #{Tag.table_name}.name HAVING COUNT(*) > 0" | |
83 | + group_by << " AND #{having}" unless having.blank? | |
84 | + | |
85 | + { :select => "#{Tag.table_name}.id, #{Tag.table_name}.name, COUNT(*) AS count", | |
86 | + :joins => "LEFT OUTER JOIN #{Tagging.table_name} ON #{Tag.table_name}.id = #{Tagging.table_name}.tag_id LEFT OUTER JOIN #{table_name} ON #{table_name}.#{primary_key} = #{Tagging.table_name}.taggable_id", | |
87 | + :conditions => conditions, | |
88 | + :group => group_by, | |
89 | + :order => options[:order], | |
90 | + :limit => options[:limit] | |
91 | + } | |
92 | + end | |
93 | + end | |
94 | + | |
95 | + module InstanceMethods | |
96 | + def tag_list | |
97 | + if @tag_list | |
98 | + @tag_list | |
99 | + elsif caching_tag_list? and !send(self.class.cached_tag_list_column_name).nil? | |
100 | + @tag_list = TagList.from(send(self.class.cached_tag_list_column_name)) | |
101 | + else | |
102 | + @tag_list = TagList.new(tags.map(&:name)) | |
103 | + end | |
104 | + end | |
105 | + | |
106 | + def tag_list=(value) | |
107 | + @tag_list = TagList.from(value) | |
108 | + end | |
109 | + | |
110 | + def save_cached_tag_list | |
111 | + if caching_tag_list? and !tag_list.blank? | |
112 | + self[self.class.cached_tag_list_column_name] = tag_list.to_s | |
113 | + end | |
114 | + end | |
115 | + | |
116 | + def save_tags | |
117 | + return unless @tag_list | |
118 | + | |
119 | + new_tag_names = @tag_list.names - tags.map(&:name) | |
120 | + old_tags = tags.reject { |tag| @tag_list.names.include?(tag.name) } | |
121 | + | |
122 | + self.class.transaction do | |
123 | + tags.delete(*old_tags) if old_tags.any? | |
124 | + | |
125 | + new_tag_names.each do |new_tag_name| | |
126 | + tags << (Tag.find(:first, :conditions => ['name like ?',new_tag_name]) || Tag.create(:name => new_tag_name)) | |
127 | + end | |
128 | + end | |
129 | + true | |
130 | + end | |
131 | + | |
132 | + def reload_with_tag_list(*args) | |
133 | + @tag_list = nil | |
134 | + reload_without_tag_list(*args) | |
135 | + end | |
136 | + | |
137 | + def caching_tag_list? | |
138 | + self.class.column_names.include?(self.class.cached_tag_list_column_name) | |
139 | + end | |
140 | + end | |
141 | + end | |
142 | + end | |
143 | +end | |
144 | + | |
145 | +ActiveRecord::Base.send(:include, ActiveRecord::Acts::Taggable) | ... | ... |
... | ... | @@ -0,0 +1,39 @@ |
1 | +class Tag < ActiveRecord::Base | |
2 | + has_many :taggings | |
3 | + | |
4 | + validates_presence_of :name | |
5 | + validates_uniqueness_of :name | |
6 | + | |
7 | + class << self | |
8 | + delegate :delimiter, :delimiter=, :to => TagList | |
9 | + end | |
10 | + | |
11 | + def ==(object) | |
12 | + super || (object.is_a?(Tag) && name == object.name) | |
13 | + end | |
14 | + | |
15 | + def to_s | |
16 | + name | |
17 | + end | |
18 | + | |
19 | + def count | |
20 | + read_attribute(:count).to_i | |
21 | + end | |
22 | + | |
23 | + def self.hierarchical=(bool) | |
24 | + if bool | |
25 | + acts_as_tree | |
26 | + end | |
27 | + end | |
28 | + | |
29 | + # All the tags that can be a new parent for this tag, that is all but itself and its descendents to avoid loops | |
30 | + def parent_candidates | |
31 | + Tag.find_all_by_pending(false) - descendents - [self] | |
32 | + end | |
33 | + | |
34 | + # All tags that have this tag as its one of its ancestors | |
35 | + def descendents | |
36 | + children.to_a.sum([], &:descendents) + children | |
37 | + end | |
38 | + | |
39 | +end | ... | ... |
vendor/plugins/acts_as_taggable_on_steroids/lib/tag_counts_extension.rb
0 → 100644
vendor/plugins/acts_as_taggable_on_steroids/lib/tag_list.rb
0 → 100644
... | ... | @@ -0,0 +1,66 @@ |
1 | +class TagList | |
2 | + cattr_accessor :delimiter | |
3 | + self.delimiter = ',' | |
4 | + | |
5 | + attr_reader :names | |
6 | + | |
7 | + def initialize(*names) | |
8 | + @names = [] | |
9 | + add(*names) | |
10 | + end | |
11 | + | |
12 | + def add(*names) | |
13 | + names = names.flatten | |
14 | + | |
15 | + # Strip whitespace and remove blank or duplicate tags | |
16 | + names.map!(&:strip) | |
17 | + names.reject!(&:blank?) | |
18 | + | |
19 | + @names.concat(names) | |
20 | + @names.uniq! | |
21 | + end | |
22 | + | |
23 | + def remove(*names) | |
24 | + names = names.flatten | |
25 | + @names.delete_if { |name| names.include?(name) } | |
26 | + end | |
27 | + | |
28 | + def blank? | |
29 | + @names.empty? | |
30 | + end | |
31 | + | |
32 | + def to_s | |
33 | + @names.map do |name| | |
34 | + name.include?(delimiter) ? "\"#{name}\"" : name | |
35 | + end.join(delimiter.ends_with?(" ") ? delimiter : "#{delimiter} ") | |
36 | + end | |
37 | + | |
38 | + def ==(other) | |
39 | + super || (other.is_a?(TagList) && other.names == @names) | |
40 | + end | |
41 | + | |
42 | + class << self | |
43 | + def from(tags) | |
44 | + case tags | |
45 | + when String | |
46 | + new(parse(tags)) | |
47 | + when Array | |
48 | + new(tags.map(&:to_s)) | |
49 | + else | |
50 | + new([]) | |
51 | + end | |
52 | + end | |
53 | + | |
54 | + def parse(string) | |
55 | + returning [] do |names| | |
56 | + string = string.to_s.dup | |
57 | + | |
58 | + # Parse the quoted tags | |
59 | + string.gsub!(/"(.*?)"\s*#{delimiter}?\s*/) { names << $1; "" } | |
60 | + string.gsub!(/'(.*?)'\s*#{delimiter}?\s*/) { names << $1; "" } | |
61 | + | |
62 | + names.concat(string.split(delimiter)) | |
63 | + end | |
64 | + end | |
65 | + end | |
66 | +end | ... | ... |
vendor/plugins/acts_as_taggable_on_steroids/lib/tagging.rb
0 → 100644
vendor/plugins/acts_as_taggable_on_steroids/test/abstract_unit.rb
0 → 100644
... | ... | @@ -0,0 +1,82 @@ |
1 | +require 'test/unit' | |
2 | + | |
3 | +begin | |
4 | + require File.dirname(__FILE__) + '/../../../../config/environment' | |
5 | +rescue LoadError | |
6 | + require 'rubygems' | |
7 | + require_gem 'activerecord' | |
8 | + require_gem 'actionpack' | |
9 | +end | |
10 | + | |
11 | +# Search for fixtures first | |
12 | +fixture_path = File.dirname(__FILE__) + '/fixtures/' | |
13 | +begin | |
14 | + Dependencies.load_paths.insert(0, fixture_path) | |
15 | +rescue | |
16 | + $LOAD_PATH.unshift(fixture_path) | |
17 | +end | |
18 | + | |
19 | +require 'active_record/fixtures' | |
20 | + | |
21 | +require File.dirname(__FILE__) + '/../lib/acts_as_taggable' | |
22 | +require_dependency File.dirname(__FILE__) + '/../lib/tag_list' | |
23 | + | |
24 | +ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + '/debug.log') | |
25 | +#ActiveRecord::Base.configurations = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml')) | |
26 | +#ActiveRecord::Base.establish_connection(ENV['DB'] || 'mysql') | |
27 | + | |
28 | +load(File.dirname(__FILE__) + '/schema.rb') | |
29 | + | |
30 | +Test::Unit::TestCase.fixture_path = fixture_path | |
31 | + | |
32 | +class Test::Unit::TestCase #:nodoc: | |
33 | + self.use_transactional_fixtures = true | |
34 | + self.use_instantiated_fixtures = false | |
35 | + | |
36 | + def assert_equivalent(expected, actual, message = nil) | |
37 | + if expected.first.is_a?(ActiveRecord::Base) | |
38 | + assert_equal expected.sort_by(&:id), actual.sort_by(&:id), message | |
39 | + else | |
40 | + assert_equal expected.sort, actual.sort, message | |
41 | + end | |
42 | + end | |
43 | + | |
44 | + def assert_tag_counts(tags, expected_values) | |
45 | + # Map the tag fixture names to real tag names | |
46 | + expected_values = expected_values.inject({}) do |hash, (tag, count)| | |
47 | + hash[tags(tag).name] = count | |
48 | + hash | |
49 | + end | |
50 | + | |
51 | + tags.each do |tag| | |
52 | + value = expected_values.delete(tag.name) | |
53 | + assert_not_nil value, "Expected count for #{tag.name} was not provided" if value.nil? | |
54 | + assert_equal value, tag.count, "Expected value of #{value} for #{tag.name}, but was #{tag.count}" | |
55 | + end | |
56 | + | |
57 | + unless expected_values.empty? | |
58 | + assert false, "The following tag counts were not present: #{expected_values.inspect}" | |
59 | + end | |
60 | + end | |
61 | + | |
62 | + def assert_queries(num = 1) | |
63 | + $query_count = 0 | |
64 | + yield | |
65 | + ensure | |
66 | + assert_equal num, $query_count, "#{$query_count} instead of #{num} queries were executed." | |
67 | + end | |
68 | + | |
69 | + def assert_no_queries(&block) | |
70 | + assert_queries(0, &block) | |
71 | + end | |
72 | +end | |
73 | + | |
74 | +ActiveRecord::Base.connection.class.class_eval do | |
75 | + def execute_with_counting(sql, name = nil, &block) | |
76 | + $query_count ||= 0 | |
77 | + $query_count += 1 | |
78 | + execute_without_counting(sql, name, &block) | |
79 | + end | |
80 | + | |
81 | + alias_method_chain :execute, :counting | |
82 | +end | ... | ... |
vendor/plugins/acts_as_taggable_on_steroids/test/acts_as_taggable_test.rb
0 → 100644
... | ... | @@ -0,0 +1,272 @@ |
1 | +require File.dirname(__FILE__) + '/abstract_unit' | |
2 | + | |
3 | +class ActsAsTaggableOnSteroidsTest < Test::Unit::TestCase | |
4 | + fixtures :tags, :taggings, :posts, :users, :photos | |
5 | + | |
6 | + def test_find_tagged_with | |
7 | + assert_equivalent [posts(:jonathan_sky), posts(:sam_flowers)], Post.find_tagged_with('"Very good"') | |
8 | + assert_equal Post.find_tagged_with('"Very good"'), Post.find_tagged_with(['Very good']) | |
9 | + assert_equal Post.find_tagged_with('"Very good"'), Post.find_tagged_with([tags(:good)]) | |
10 | + | |
11 | + assert_equivalent [photos(:jonathan_dog), photos(:sam_flower), photos(:sam_sky)], Photo.find_tagged_with('Nature') | |
12 | + assert_equal Photo.find_tagged_with('Nature'), Photo.find_tagged_with(['Nature']) | |
13 | + assert_equal Photo.find_tagged_with('Nature'), Photo.find_tagged_with([tags(:nature)]) | |
14 | + | |
15 | + assert_equivalent [photos(:jonathan_bad_cat), photos(:jonathan_dog), photos(:jonathan_questioning_dog)], Photo.find_tagged_with('"Crazy animal" Bad') | |
16 | + assert_equal Photo.find_tagged_with('"Crazy animal" Bad'), Photo.find_tagged_with(['Crazy animal', 'Bad']) | |
17 | + assert_equal Photo.find_tagged_with('"Crazy animal" Bad'), Photo.find_tagged_with([tags(:animal), tags(:bad)]) | |
18 | + end | |
19 | + | |
20 | + def test_find_tagged_with_nothing | |
21 | + assert_equal [], Post.find_tagged_with("") | |
22 | + assert_equal [], Post.find_tagged_with([]) | |
23 | + end | |
24 | + | |
25 | + def test_find_tagged_with_nonexistant_tags | |
26 | + assert_equal [], Post.find_tagged_with('ABCDEFG') | |
27 | + assert_equal [], Photo.find_tagged_with(['HIJKLM']) | |
28 | + assert_equal [], Photo.find_tagged_with([Tag.new(:name => 'unsaved tag')]) | |
29 | + end | |
30 | + | |
31 | + def test_find_tagged_with_matching_all_tags | |
32 | + assert_equivalent [photos(:jonathan_dog)], Photo.find_tagged_with('Crazy animal, "Nature"', :match_all => true) | |
33 | + assert_equivalent [posts(:jonathan_sky), posts(:sam_flowers)], Post.find_tagged_with(['Very good', 'Nature'], :match_all => true) | |
34 | + end | |
35 | + | |
36 | + def test_find_tagged_with_exclusions | |
37 | + assert_equivalent [photos(:jonathan_questioning_dog), photos(:jonathan_bad_cat)], Photo.find_tagged_with("Nature", :exclude => true) | |
38 | + assert_equivalent [posts(:jonathan_grass), posts(:jonathan_rain)], Post.find_tagged_with("'Very good', Bad", :exclude => true) | |
39 | + end | |
40 | + | |
41 | +# def test_find_options_for_tagged_with_no_tags_returns_empty_hash | |
42 | +# assert_equal Hash.new, Post.find_options_for_tagged_with("") | |
43 | +# assert_equal Hash.new, Post.find_options_for_tagged_with([nil]) | |
44 | +# end | |
45 | + | |
46 | +# def test_find_options_for_tagged_with_leavs_arguments_unchanged | |
47 | +# original_tags = photos(:jonathan_questioning_dog).tags.dup | |
48 | +# Photo.find_options_for_tagged_with(photos(:jonathan_questioning_dog).tags) | |
49 | +# assert_equal original_tags, photos(:jonathan_questioning_dog).tags | |
50 | +# end | |
51 | + | |
52 | +# def test_find_options_for_tagged_with_respects_custom_table_name | |
53 | +# Tagging.table_name = "categorisations" | |
54 | +# Tag.table_name = "categories" | |
55 | +# | |
56 | +# options = Photo.find_options_for_tagged_with("Hello") | |
57 | +# | |
58 | +# assert_no_match Regexp.new(" taggings "), options[:joins] | |
59 | +# assert_no_match Regexp.new(" tags "), options[:joins] | |
60 | +# | |
61 | +# assert_match Regexp.new(" categorisations "), options[:joins] | |
62 | +# assert_match Regexp.new(" categories "), options[:joins] | |
63 | +# ensure | |
64 | +# Tagging.table_name = "taggings" | |
65 | +# Tag.table_name = "tags" | |
66 | +# end | |
67 | + | |
68 | + def test_include_tags_on_find_tagged_with | |
69 | + assert_nothing_raised do | |
70 | + Photo.find_tagged_with('Nature', :include => :tags) | |
71 | + Photo.find_tagged_with("Nature", :include => { :taggings => :tag }) | |
72 | + end | |
73 | + end | |
74 | + | |
75 | + def test_basic_tag_counts_on_class | |
76 | + assert_tag_counts Post.tag_counts, :good => 2, :nature => 5, :question => 1, :bad => 1 | |
77 | + assert_tag_counts Photo.tag_counts, :good => 1, :nature => 3, :question => 1, :bad => 1, :animal => 3 | |
78 | + end | |
79 | + | |
80 | + def test_tag_counts_on_class_with_date_conditions | |
81 | + assert_tag_counts Post.tag_counts(:start_at => Date.new(2006, 8, 4)), :good => 1, :nature => 3, :question => 1, :bad => 1 | |
82 | + assert_tag_counts Post.tag_counts(:end_at => Date.new(2006, 8, 6)), :good => 1, :nature => 4, :question => 1 | |
83 | + assert_tag_counts Post.tag_counts(:start_at => Date.new(2006, 8, 5), :end_at => Date.new(2006, 8, 8)), :good => 1, :nature => 2, :bad => 1 | |
84 | + | |
85 | + assert_tag_counts Photo.tag_counts(:start_at => Date.new(2006, 8, 12), :end_at => Date.new(2006, 8, 17)), :good => 1, :nature => 1, :bad => 1, :question => 1, :animal => 2 | |
86 | + end | |
87 | + | |
88 | + def test_tag_counts_on_class_with_frequencies | |
89 | + assert_tag_counts Photo.tag_counts(:at_least => 2), :nature => 3, :animal => 3 | |
90 | + assert_tag_counts Photo.tag_counts(:at_most => 2), :good => 1, :question => 1, :bad => 1 | |
91 | + end | |
92 | + | |
93 | + def test_tag_counts_with_limit | |
94 | + assert_equal 2, Photo.tag_counts(:limit => 2).size | |
95 | + assert_equal 1, Post.tag_counts(:at_least => 4, :limit => 2).size | |
96 | + end | |
97 | + | |
98 | + def test_tag_counts_with_limit_and_order | |
99 | + assert_equal [tags(:nature), tags(:good)], Post.tag_counts(:order => 'count desc', :limit => 2) | |
100 | + end | |
101 | + | |
102 | + def test_tag_counts_on_association | |
103 | + assert_tag_counts users(:jonathan).posts.tag_counts, :good => 1, :nature => 3, :question => 1 | |
104 | + assert_tag_counts users(:sam).posts.tag_counts, :good => 1, :nature => 2, :bad => 1 | |
105 | + | |
106 | + assert_tag_counts users(:jonathan).photos.tag_counts, :animal => 3, :nature => 1, :question => 1, :bad => 1 | |
107 | + assert_tag_counts users(:sam).photos.tag_counts, :nature => 2, :good => 1 | |
108 | + end | |
109 | + | |
110 | + def test_tag_counts_on_association_with_options | |
111 | + assert_equal [], users(:jonathan).posts.tag_counts(:conditions => '1=0') | |
112 | + assert_tag_counts users(:jonathan).posts.tag_counts(:at_most => 2), :good => 1, :question => 1 | |
113 | + end | |
114 | + | |
115 | + def test_tag_counts_respects_custom_table_names | |
116 | + Tagging.table_name = "categorisations" | |
117 | + Tag.table_name = "categories" | |
118 | + | |
119 | + options = Photo.find_options_for_tag_counts(:start_at => 2.weeks.ago, :end_at => Date.today) | |
120 | + sql = options.values.join(' ') | |
121 | + | |
122 | + assert_no_match /taggings/, sql | |
123 | + assert_no_match /tags/, sql | |
124 | + | |
125 | + assert_match /categorisations/, sql | |
126 | + assert_match /categories/, sql | |
127 | + ensure | |
128 | + Tagging.table_name = "taggings" | |
129 | + Tag.table_name = "tags" | |
130 | + end | |
131 | + | |
132 | + def test_tag_list_reader | |
133 | + assert_equivalent ["Very good", "Nature"], posts(:jonathan_sky).tag_list.names | |
134 | + assert_equivalent ["Bad", "Crazy animal"], photos(:jonathan_bad_cat).tag_list.names | |
135 | + end | |
136 | + | |
137 | + def test_reassign_tag_list | |
138 | + assert_equivalent ["Nature", "Question"], posts(:jonathan_rain).tag_list.names | |
139 | + posts(:jonathan_rain).taggings.reload | |
140 | + | |
141 | + # Only an update of the posts table should be executed | |
142 | + assert_queries 1 do | |
143 | + posts(:jonathan_rain).update_attributes!(:tag_list => posts(:jonathan_rain).tag_list.to_s) | |
144 | + end | |
145 | + | |
146 | + assert_equivalent ["Nature", "Question"], posts(:jonathan_rain).tag_list.names | |
147 | + end | |
148 | + | |
149 | + def test_new_tags | |
150 | + assert_equivalent ["Very good", "Nature"], posts(:jonathan_sky).tag_list.names | |
151 | + posts(:jonathan_sky).update_attributes!(:tag_list => "#{posts(:jonathan_sky).tag_list}, One, Two") | |
152 | + assert_equivalent ["Very good", "Nature", "One", "Two"], posts(:jonathan_sky).tag_list.names | |
153 | + end | |
154 | + | |
155 | + def test_remove_tag | |
156 | + assert_equivalent ["Very good", "Nature"], posts(:jonathan_sky).tag_list.names | |
157 | + posts(:jonathan_sky).update_attributes!(:tag_list => "Nature") | |
158 | + assert_equivalent ["Nature"], posts(:jonathan_sky).tag_list.names | |
159 | + end | |
160 | + | |
161 | + def test_change_case_of_tags | |
162 | + original_tag_names = photos(:jonathan_questioning_dog).tag_list.names | |
163 | + photos(:jonathan_questioning_dog).update_attributes!(:tag_list => photos(:jonathan_questioning_dog).tag_list.to_s.upcase) | |
164 | + | |
165 | + # The new tag list is not uppercase becuase the AR finders are not case-sensitive | |
166 | + # and find the old tags when re-tagging with the uppercase tags. | |
167 | + assert_equivalent original_tag_names, photos(:jonathan_questioning_dog).reload.tag_list.names | |
168 | + end | |
169 | + | |
170 | + def test_remove_and_add_tag | |
171 | + assert_equivalent ["Very good", "Nature"], posts(:jonathan_sky).tag_list.names | |
172 | + posts(:jonathan_sky).update_attributes!(:tag_list => "Nature, Beautiful") | |
173 | + assert_equivalent ["Nature", "Beautiful"], posts(:jonathan_sky).tag_list.names | |
174 | + end | |
175 | + | |
176 | + def test_tags_not_saved_if_validation_fails | |
177 | + assert_equivalent ["Very good", "Nature"], posts(:jonathan_sky).tag_list.names | |
178 | + assert !posts(:jonathan_sky).update_attributes(:tag_list => "One, Two", :text => "") | |
179 | + assert_equivalent ["Very good", "Nature"], Post.find(posts(:jonathan_sky).id).tag_list.names | |
180 | + end | |
181 | + | |
182 | + def test_tag_list_accessors_on_new_record | |
183 | + p = Post.new(:text => 'Test') | |
184 | + | |
185 | + assert p.tag_list.blank? | |
186 | + p.tag_list = "One, Two" | |
187 | + assert_equal "One, Two", p.tag_list.to_s | |
188 | + end | |
189 | + | |
190 | + def test_clear_tag_list_with_nil | |
191 | + p = photos(:jonathan_questioning_dog) | |
192 | + | |
193 | + assert !p.tag_list.blank? | |
194 | + assert p.update_attributes(:tag_list => nil) | |
195 | + assert p.tag_list.blank? | |
196 | + | |
197 | + assert p.reload.tag_list.blank? | |
198 | + end | |
199 | + | |
200 | + def test_clear_tag_list_with_string | |
201 | + p = photos(:jonathan_questioning_dog) | |
202 | + | |
203 | + assert !p.tag_list.blank? | |
204 | + assert p.update_attributes(:tag_list => ' ') | |
205 | + assert p.tag_list.blank? | |
206 | + | |
207 | + assert p.reload.tag_list.blank? | |
208 | + end | |
209 | + | |
210 | + def test_tag_list_reset_on_reload | |
211 | + p = photos(:jonathan_questioning_dog) | |
212 | + assert !p.tag_list.blank? | |
213 | + p.tag_list = nil | |
214 | + assert p.tag_list.blank? | |
215 | + assert !p.reload.tag_list.blank? | |
216 | + end | |
217 | + | |
218 | + def test_tag_list_populated_when_cache_nil | |
219 | + assert_nil posts(:jonathan_sky).cached_tag_list | |
220 | + posts(:jonathan_sky).save! | |
221 | + assert_equal posts(:jonathan_sky).tag_list.to_s, posts(:jonathan_sky).cached_tag_list | |
222 | + end | |
223 | + | |
224 | + def test_cached_tag_list_used | |
225 | + posts(:jonathan_sky).save! | |
226 | + posts(:jonathan_sky).reload | |
227 | + | |
228 | + assert_no_queries do | |
229 | + assert_equivalent ["Very good", "Nature"], posts(:jonathan_sky).tag_list.names | |
230 | + end | |
231 | + end | |
232 | + | |
233 | + def test_cached_tag_list_not_used | |
234 | + # Load fixture and column information | |
235 | + posts(:jonathan_sky).taggings(:reload) | |
236 | + | |
237 | + assert_queries 1 do | |
238 | + # Tags association will be loaded | |
239 | + posts(:jonathan_sky).tag_list | |
240 | + end | |
241 | + end | |
242 | + | |
243 | + def test_cached_tag_list_updated | |
244 | + assert_nil posts(:jonathan_sky).cached_tag_list | |
245 | + posts(:jonathan_sky).save! | |
246 | + assert_equivalent ["Very good", "Nature"], TagList.from(posts(:jonathan_sky).cached_tag_list).names | |
247 | + posts(:jonathan_sky).update_attributes!(:tag_list => "None") | |
248 | + | |
249 | + assert_equal 'None', posts(:jonathan_sky).cached_tag_list | |
250 | + assert_equal 'None', posts(:jonathan_sky).reload.cached_tag_list | |
251 | + end | |
252 | + | |
253 | + def test_inherited_taggable | |
254 | + # SpPost inherits acts_as_taggable from its ancestor Post | |
255 | + p = SpPost.new(:text => 'bla bla bla ...') | |
256 | + p.tag_list = 'bla' | |
257 | + p.save | |
258 | + assert !SpPost.find_tagged_with('bla').blank? | |
259 | + end | |
260 | +end | |
261 | + | |
262 | +class ActsAsTaggableOnSteroidsFormTest < Test::Unit::TestCase | |
263 | + fixtures :tags, :taggings, :posts, :users, :photos | |
264 | + | |
265 | + include ActionView::Helpers::FormHelper | |
266 | + | |
267 | + def test_tag_list_contents | |
268 | + fields_for :post, posts(:jonathan_sky) do |f| | |
269 | + assert_match /Very good, Nature/, f.text_field(:tag_list) | |
270 | + end | |
271 | + end | |
272 | +end | ... | ... |
vendor/plugins/acts_as_taggable_on_steroids/test/database.yml
0 → 100644
vendor/plugins/acts_as_taggable_on_steroids/test/fixtures/photo.rb
0 → 100644
vendor/plugins/acts_as_taggable_on_steroids/test/fixtures/photos.yml
0 → 100644
... | ... | @@ -0,0 +1,24 @@ |
1 | +jonathan_dog: | |
2 | + id: 1 | |
3 | + user_id: 1 | |
4 | + title: A small dog | |
5 | + | |
6 | +jonathan_questioning_dog: | |
7 | + id: 2 | |
8 | + user_id: 1 | |
9 | + title: What does this dog want? | |
10 | + | |
11 | +jonathan_bad_cat: | |
12 | + id: 3 | |
13 | + user_id: 1 | |
14 | + title: Bad cat | |
15 | + | |
16 | +sam_flower: | |
17 | + id: 4 | |
18 | + user_id: 2 | |
19 | + title: Flower | |
20 | + | |
21 | +sam_sky: | |
22 | + id: 5 | |
23 | + user_id: 2 | |
24 | + title: Sky | ... | ... |
vendor/plugins/acts_as_taggable_on_steroids/test/fixtures/post.rb
0 → 100644
vendor/plugins/acts_as_taggable_on_steroids/test/fixtures/posts.yml
0 → 100644
... | ... | @@ -0,0 +1,24 @@ |
1 | +jonathan_sky: | |
2 | + id: 1 | |
3 | + user_id: 1 | |
4 | + text: The sky is particularly blue today | |
5 | + | |
6 | +jonathan_grass: | |
7 | + id: 2 | |
8 | + user_id: 1 | |
9 | + text: The grass seems very green | |
10 | + | |
11 | +jonathan_rain: | |
12 | + id: 3 | |
13 | + user_id: 1 | |
14 | + text: Why does the rain fall? | |
15 | + | |
16 | +sam_ground: | |
17 | + id: 4 | |
18 | + user_id: 2 | |
19 | + text: The ground is looking too brown | |
20 | + | |
21 | +sam_flowers: | |
22 | + id: 5 | |
23 | + user_id: 2 | |
24 | + text: Why are the flowers dead? | |
0 | 25 | \ No newline at end of file | ... | ... |
vendor/plugins/acts_as_taggable_on_steroids/test/fixtures/sp_post.rb
0 → 100644
vendor/plugins/acts_as_taggable_on_steroids/test/fixtures/taggings.yml
0 → 100644
... | ... | @@ -0,0 +1,126 @@ |
1 | +jonathan_sky_good: | |
2 | + id: 1 | |
3 | + tag_id: 1 | |
4 | + taggable_id: 1 | |
5 | + taggable_type: Post | |
6 | + created_at: 2006-08-01 | |
7 | + | |
8 | +jonathan_sky_nature: | |
9 | + id: 2 | |
10 | + tag_id: 3 | |
11 | + taggable_id: 1 | |
12 | + taggable_type: Post | |
13 | + created_at: 2006-08-02 | |
14 | + | |
15 | +jonathan_grass_nature: | |
16 | + id: 3 | |
17 | + tag_id: 3 | |
18 | + taggable_id: 2 | |
19 | + taggable_type: Post | |
20 | + created_at: 2006-08-03 | |
21 | + | |
22 | +jonathan_rain_question: | |
23 | + id: 4 | |
24 | + tag_id: 4 | |
25 | + taggable_id: 3 | |
26 | + taggable_type: Post | |
27 | + created_at: 2006-08-04 | |
28 | + | |
29 | +jonathan_rain_nature: | |
30 | + id: 5 | |
31 | + tag_id: 3 | |
32 | + taggable_id: 3 | |
33 | + taggable_type: Post | |
34 | + created_at: 2006-08-05 | |
35 | + | |
36 | +sam_ground_nature: | |
37 | + id: 6 | |
38 | + tag_id: 3 | |
39 | + taggable_id: 4 | |
40 | + taggable_type: Post | |
41 | + created_at: 2006-08-06 | |
42 | + | |
43 | +sam_ground_bad: | |
44 | + id: 7 | |
45 | + tag_id: 2 | |
46 | + taggable_id: 4 | |
47 | + taggable_type: Post | |
48 | + created_at: 2006-08-07 | |
49 | + | |
50 | +sam_flowers_good: | |
51 | + id: 8 | |
52 | + tag_id: 1 | |
53 | + taggable_id: 5 | |
54 | + taggable_type: Post | |
55 | + created_at: 2006-08-08 | |
56 | + | |
57 | +sam_flowers_nature: | |
58 | + id: 9 | |
59 | + tag_id: 3 | |
60 | + taggable_id: 5 | |
61 | + taggable_type: Post | |
62 | + created_at: 2006-08-09 | |
63 | + | |
64 | + | |
65 | +jonathan_dog_animal: | |
66 | + id: 10 | |
67 | + tag_id: 5 | |
68 | + taggable_id: 1 | |
69 | + taggable_type: Photo | |
70 | + created_at: 2006-08-10 | |
71 | + | |
72 | +jonathan_dog_nature: | |
73 | + id: 11 | |
74 | + tag_id: 3 | |
75 | + taggable_id: 1 | |
76 | + taggable_type: Photo | |
77 | + created_at: 2006-08-11 | |
78 | + | |
79 | +jonathan_questioning_dog_animal: | |
80 | + id: 12 | |
81 | + tag_id: 5 | |
82 | + taggable_id: 2 | |
83 | + taggable_type: Photo | |
84 | + created_at: 2006-08-12 | |
85 | + | |
86 | +jonathan_questioning_dog_question: | |
87 | + id: 13 | |
88 | + tag_id: 4 | |
89 | + taggable_id: 2 | |
90 | + taggable_type: Photo | |
91 | + created_at: 2006-08-13 | |
92 | + | |
93 | +jonathan_bad_cat_bad: | |
94 | + id: 14 | |
95 | + tag_id: 2 | |
96 | + taggable_id: 3 | |
97 | + taggable_type: Photo | |
98 | + created_at: 2006-08-14 | |
99 | + | |
100 | +jonathan_bad_cat_animal: | |
101 | + id: 15 | |
102 | + tag_id: 5 | |
103 | + taggable_id: 3 | |
104 | + taggable_type: Photo | |
105 | + created_at: 2006-08-15 | |
106 | + | |
107 | +sam_flower_nature: | |
108 | + id: 16 | |
109 | + tag_id: 3 | |
110 | + taggable_id: 4 | |
111 | + taggable_type: Photo | |
112 | + created_at: 2006-08-16 | |
113 | + | |
114 | +sam_flower_good: | |
115 | + id: 17 | |
116 | + tag_id: 1 | |
117 | + taggable_id: 4 | |
118 | + taggable_type: Photo | |
119 | + created_at: 2006-08-17 | |
120 | + | |
121 | +sam_sky_nature: | |
122 | + id: 18 | |
123 | + tag_id: 3 | |
124 | + taggable_id: 5 | |
125 | + taggable_type: Photo | |
126 | + created_at: 2006-08-18 | ... | ... |
vendor/plugins/acts_as_taggable_on_steroids/test/fixtures/tags.yml
0 → 100644
vendor/plugins/acts_as_taggable_on_steroids/test/fixtures/user.rb
0 → 100644
vendor/plugins/acts_as_taggable_on_steroids/test/fixtures/users.yml
0 → 100644
vendor/plugins/acts_as_taggable_on_steroids/test/schema.rb
0 → 100644
... | ... | @@ -0,0 +1,31 @@ |
1 | +ActiveRecord::Schema.define :version => 0 do | |
2 | + create_table :tags, :force => true do |t| | |
3 | + t.column :name, :string | |
4 | + t.column :parent_id, :integer | |
5 | + t.column :pending, :boolean | |
6 | + end | |
7 | + | |
8 | + create_table :taggings, :force => true do |t| | |
9 | + t.column :tag_id, :integer | |
10 | + t.column :taggable_id, :integer | |
11 | + t.column :taggable_type, :string | |
12 | + t.column :created_at, :datetime | |
13 | + end | |
14 | + | |
15 | + create_table :users, :force => true do |t| | |
16 | + t.column :name, :string | |
17 | + end | |
18 | + | |
19 | + create_table :posts, :force => true do |t| | |
20 | + t.column :text, :text | |
21 | + t.column :cached_tag_list, :string | |
22 | + t.column :user_id, :integer | |
23 | + | |
24 | + t.column :type, :string | |
25 | + end | |
26 | + | |
27 | + create_table :photos, :force => true do |t| | |
28 | + t.column :title, :string | |
29 | + t.column :user_id, :integer | |
30 | + end | |
31 | +end | ... | ... |
vendor/plugins/acts_as_taggable_on_steroids/test/tag_list_test.rb
0 → 100644
... | ... | @@ -0,0 +1,98 @@ |
1 | +require File.dirname(__FILE__) + '/abstract_unit' | |
2 | + | |
3 | +class TagListTest < Test::Unit::TestCase | |
4 | + def test_blank? | |
5 | + assert TagList.new.blank? | |
6 | + end | |
7 | + | |
8 | + def test_equality | |
9 | + assert_equal TagList.new, TagList.new | |
10 | + assert_equal TagList.new("tag"), TagList.new("tag") | |
11 | + | |
12 | + assert_not_equal TagList.new, "" | |
13 | + assert_not_equal TagList.new, TagList.new("tag") | |
14 | + end | |
15 | + | |
16 | + def test_parse_leaves_string_unchanged | |
17 | + tags = '"one ", two' | |
18 | + original = tags.dup | |
19 | + TagList.parse(tags) | |
20 | + assert_equal tags, original | |
21 | + end | |
22 | + | |
23 | + def test_from_single_name | |
24 | + assert_equal %w(fun), TagList.from("fun").names | |
25 | + assert_equal %w(fun), TagList.from('"fun"').names | |
26 | + end | |
27 | + | |
28 | + def test_from_blank | |
29 | + assert_equal [], TagList.from(nil).names | |
30 | + assert_equal [], TagList.from("").names | |
31 | + end | |
32 | + | |
33 | + def test_from_single_quoted_tag | |
34 | + assert_equal ['with, comma'], TagList.from('"with, comma"').names | |
35 | + end | |
36 | + | |
37 | + def test_spaces_do_not_delineate | |
38 | + assert_equal ['a b', 'c'], TagList.from('a b, c').names | |
39 | + end | |
40 | + | |
41 | + def test_from_multiple_tags | |
42 | + assert_equivalent %w(alpha beta delta gamma), TagList.from("alpha, beta, delta, gamma").names.sort | |
43 | + end | |
44 | + | |
45 | + def test_from_multiple_tags_with_quotes | |
46 | + assert_equivalent %w(alpha beta delta gamma), TagList.from('alpha, "beta", gamma , "delta"').names.sort | |
47 | + end | |
48 | + | |
49 | + def test_from_multiple_tags_with_quote_and_commas | |
50 | + assert_equivalent ['alpha, beta', 'delta', 'gamma, something'], TagList.from('"alpha, beta", delta, "gamma, something"').names | |
51 | + end | |
52 | + | |
53 | + def test_from_removes_white_space | |
54 | + assert_equivalent %w(alpha beta), TagList.from('" alpha ", "beta "').names | |
55 | + assert_equivalent %w(alpha beta), TagList.from(' alpha, beta ').names | |
56 | + end | |
57 | + | |
58 | + def test_alternative_delimiter | |
59 | + TagList.delimiter = " " | |
60 | + | |
61 | + assert_equal %w(one two), TagList.from("one two").names | |
62 | + assert_equal ['one two', 'three', 'four'], TagList.from('"one two" three four').names | |
63 | + ensure | |
64 | + TagList.delimiter = "," | |
65 | + end | |
66 | + | |
67 | + def test_duplicate_tags_removed | |
68 | + assert_equal %w(one), TagList.from("one, one").names | |
69 | + end | |
70 | + | |
71 | + def test_to_s_with_commas | |
72 | + assert_equal "question, crazy animal", TagList.new(["question", "crazy animal"]).to_s | |
73 | + end | |
74 | + | |
75 | + def test_to_s_with_alternative_delimiter | |
76 | + TagList.delimiter = " " | |
77 | + | |
78 | + assert_equal '"crazy animal" question', TagList.new(["crazy animal", "question"]).to_s | |
79 | + ensure | |
80 | + TagList.delimiter = "," | |
81 | + end | |
82 | + | |
83 | + def test_add | |
84 | + tag_list = TagList.new("one") | |
85 | + assert_equal %w(one), tag_list.names | |
86 | + | |
87 | + tag_list.add("two") | |
88 | + assert_equal %w(one two), tag_list.names | |
89 | + end | |
90 | + | |
91 | + def test_remove | |
92 | + tag_list = TagList.new("one", "two") | |
93 | + assert_equal %w(one two), tag_list.names | |
94 | + | |
95 | + tag_list.remove("one") | |
96 | + assert_equal %w(two), tag_list.names | |
97 | + end | |
98 | +end | ... | ... |
vendor/plugins/acts_as_taggable_on_steroids/test/tag_test.rb
0 → 100644
... | ... | @@ -0,0 +1,42 @@ |
1 | +require File.dirname(__FILE__) + '/abstract_unit' | |
2 | + | |
3 | +class TagTest < Test::Unit::TestCase | |
4 | + fixtures :tags, :taggings, :users, :photos, :posts | |
5 | + | |
6 | + def test_name_required | |
7 | + t = Tag.create | |
8 | + assert_match /blank/, t.errors[:name].to_s | |
9 | + end | |
10 | + | |
11 | + def test_name_unique | |
12 | + t = Tag.create!(:name => "My tag") | |
13 | + duplicate = t.clone | |
14 | + | |
15 | + assert !duplicate.save | |
16 | + assert_match /taken/, duplicate.errors[:name].to_s | |
17 | + end | |
18 | + | |
19 | + def test_taggings | |
20 | + assert_equivalent [taggings(:jonathan_sky_good), taggings(:sam_flowers_good), taggings(:sam_flower_good)], tags(:good).taggings | |
21 | + assert_equivalent [taggings(:sam_ground_bad), taggings(:jonathan_bad_cat_bad)], tags(:bad).taggings | |
22 | + end | |
23 | + | |
24 | + def test_to_s | |
25 | + assert_equal tags(:good).name, tags(:good).to_s | |
26 | + end | |
27 | + | |
28 | + def test_equality | |
29 | + assert_equal tags(:good), tags(:good) | |
30 | + assert_equal Tag.find(1), Tag.find(1) | |
31 | + assert_equal Tag.new(:name => 'A'), Tag.new(:name => 'A') | |
32 | + assert_not_equal Tag.new(:name => 'A'), Tag.new(:name => 'B') | |
33 | + end | |
34 | + | |
35 | + def test_deprecated_delimiter | |
36 | + original_delimiter = Tag.delimiter | |
37 | + Tag.delimiter = ":" | |
38 | + assert_equal ":", TagList.delimiter | |
39 | + ensure | |
40 | + TagList.delimiter = original_delimiter | |
41 | + end | |
42 | +end | ... | ... |
vendor/plugins/acts_as_taggable_on_steroids/test/tagging_test.rb
0 → 100644
... | ... | @@ -0,0 +1,13 @@ |
1 | +require File.dirname(__FILE__) + '/abstract_unit' | |
2 | + | |
3 | +class TaggingTest < Test::Unit::TestCase | |
4 | + fixtures :tags, :taggings, :posts | |
5 | + | |
6 | + def test_tag | |
7 | + assert_equal tags(:good), taggings(:jonathan_sky_good).tag | |
8 | + end | |
9 | + | |
10 | + def test_taggable | |
11 | + assert_equal posts(:jonathan_sky), taggings(:jonathan_sky_good).taggable | |
12 | + end | |
13 | +end | ... | ... |
... | ... | @@ -0,0 +1,74 @@ |
1 | +*SVN* (version numbers are overrated) | |
2 | + | |
3 | +* (5 Oct 2006) Allow customization of #versions association options [Dan Peterson] | |
4 | + | |
5 | +*0.5.1* | |
6 | + | |
7 | +* (8 Aug 2006) Versioned models now belong to the unversioned model. @article_version.article.class => Article [Aslak Hellesoy] | |
8 | + | |
9 | +*0.5* # do versions even matter for plugins? | |
10 | + | |
11 | +* (21 Apr 2006) Added without_locking and without_revision methods. | |
12 | + | |
13 | + Foo.without_revision do | |
14 | + @foo.update_attributes ... | |
15 | + end | |
16 | + | |
17 | +*0.4* | |
18 | + | |
19 | +* (28 March 2006) Rename non_versioned_fields to non_versioned_columns (old one is kept for compatibility). | |
20 | +* (28 March 2006) Made explicit documentation note that string column names are required for non_versioned_columns. | |
21 | + | |
22 | +*0.3.1* | |
23 | + | |
24 | +* (7 Jan 2006) explicitly set :foreign_key option for the versioned model's belongs_to assocation for STI [Caged] | |
25 | +* (7 Jan 2006) added tests to prove has_many :through joins work | |
26 | + | |
27 | +*0.3* | |
28 | + | |
29 | +* (2 Jan 2006) added ability to share a mixin with versioned class | |
30 | +* (2 Jan 2006) changed the dynamic version model to MyModel::Version | |
31 | + | |
32 | +*0.2.4* | |
33 | + | |
34 | +* (27 Nov 2005) added note about possible destructive behavior of if_changed? [Michael Schuerig] | |
35 | + | |
36 | +*0.2.3* | |
37 | + | |
38 | +* (12 Nov 2005) fixed bug with old behavior of #blank? [Michael Schuerig] | |
39 | +* (12 Nov 2005) updated tests to use ActiveRecord Schema | |
40 | + | |
41 | +*0.2.2* | |
42 | + | |
43 | +* (3 Nov 2005) added documentation note to #acts_as_versioned [Martin Jul] | |
44 | + | |
45 | +*0.2.1* | |
46 | + | |
47 | +* (6 Oct 2005) renamed dirty? to changed? to keep it uniform. it was aliased to keep it backwards compatible. | |
48 | + | |
49 | +*0.2* | |
50 | + | |
51 | +* (6 Oct 2005) added find_versions and find_version class methods. | |
52 | + | |
53 | +* (6 Oct 2005) removed transaction from create_versioned_table(). | |
54 | + this way you can specify your own transaction around a group of operations. | |
55 | + | |
56 | +* (30 Sep 2005) fixed bug where find_versions() would order by 'version' twice. (found by Joe Clark) | |
57 | + | |
58 | +* (26 Sep 2005) added :sequence_name option to acts_as_versioned to set the sequence name on the versioned model | |
59 | + | |
60 | +*0.1.3* (18 Sep 2005) | |
61 | + | |
62 | +* First RubyForge release | |
63 | + | |
64 | +*0.1.2* | |
65 | + | |
66 | +* check if module is already included when acts_as_versioned is called | |
67 | + | |
68 | +*0.1.1* | |
69 | + | |
70 | +* Adding tests and rdocs | |
71 | + | |
72 | +*0.1* | |
73 | + | |
74 | +* Initial transfer from Rails ticket: http://dev.rubyonrails.com/ticket/1974 | |
0 | 75 | \ No newline at end of file | ... | ... |
... | ... | @@ -0,0 +1,20 @@ |
1 | +Copyright (c) 2005 Rick Olson | |
2 | + | |
3 | +Permission is hereby granted, free of charge, to any person obtaining | |
4 | +a copy of this software and associated documentation files (the | |
5 | +"Software"), to deal in the Software without restriction, including | |
6 | +without limitation the rights to use, copy, modify, merge, publish, | |
7 | +distribute, sublicense, and/or sell copies of the Software, and to | |
8 | +permit persons to whom the Software is furnished to do so, subject to | |
9 | +the following conditions: | |
10 | + | |
11 | +The above copyright notice and this permission notice shall be | |
12 | +included in all copies or substantial portions of the Software. | |
13 | + | |
14 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
15 | +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
16 | +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |
17 | +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | |
18 | +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | |
19 | +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | |
20 | +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
0 | 21 | \ No newline at end of file | ... | ... |
... | ... | @@ -0,0 +1,28 @@ |
1 | += acts_as_versioned | |
2 | + | |
3 | +This library adds simple versioning to an ActiveRecord module. ActiveRecord is required. | |
4 | + | |
5 | +== Resources | |
6 | + | |
7 | +Install | |
8 | + | |
9 | +* gem install acts_as_versioned | |
10 | + | |
11 | +Rubyforge project | |
12 | + | |
13 | +* http://rubyforge.org/projects/ar-versioned | |
14 | + | |
15 | +RDocs | |
16 | + | |
17 | +* http://ar-versioned.rubyforge.org | |
18 | + | |
19 | +Subversion | |
20 | + | |
21 | +* http://techno-weenie.net/svn/projects/acts_as_versioned | |
22 | + | |
23 | +Collaboa | |
24 | + | |
25 | +* http://collaboa.techno-weenie.net/repository/browse/acts_as_versioned | |
26 | + | |
27 | +Special thanks to Dreamer on ##rubyonrails for help in early testing. His ServerSideWiki (http://serversidewiki.com) | |
28 | +was the first project to use acts_as_versioned <em>in the wild</em>. | |
0 | 29 | \ No newline at end of file | ... | ... |
... | ... | @@ -0,0 +1,41 @@ |
1 | +== Creating the test database | |
2 | + | |
3 | +The default name for the test databases is "activerecord_versioned". If you | |
4 | +want to use another database name then be sure to update the connection | |
5 | +adapter setups you want to test with in test/connections/<your database>/connection.rb. | |
6 | +When you have the database online, you can import the fixture tables with | |
7 | +the test/fixtures/db_definitions/*.sql files. | |
8 | + | |
9 | +Make sure that you create database objects with the same user that you specified in i | |
10 | +connection.rb otherwise (on Postgres, at least) tests for default values will fail. | |
11 | + | |
12 | +== Running with Rake | |
13 | + | |
14 | +The easiest way to run the unit tests is through Rake. The default task runs | |
15 | +the entire test suite for all the adapters. You can also run the suite on just | |
16 | +one adapter by using the tasks test_mysql_ruby, test_ruby_mysql, test_sqlite, | |
17 | +or test_postresql. For more information, checkout the full array of rake tasks with "rake -T" | |
18 | + | |
19 | +Rake can be found at http://rake.rubyforge.org | |
20 | + | |
21 | +== Running by hand | |
22 | + | |
23 | +Unit tests are located in test directory. If you only want to run a single test suite, | |
24 | +or don't want to bother with Rake, you can do so with something like: | |
25 | + | |
26 | + cd test; ruby -I "connections/native_mysql" base_test.rb | |
27 | + | |
28 | +That'll run the base suite using the MySQL-Ruby adapter. Change the adapter | |
29 | +and test suite name as needed. | |
30 | + | |
31 | +== Faster tests | |
32 | + | |
33 | +If you are using a database that supports transactions, you can set the | |
34 | +"AR_TX_FIXTURES" environment variable to "yes" to use transactional fixtures. | |
35 | +This gives a very large speed boost. With rake: | |
36 | + | |
37 | + rake AR_TX_FIXTURES=yes | |
38 | + | |
39 | +Or, by hand: | |
40 | + | |
41 | + AR_TX_FIXTURES=yes ruby -I connections/native_sqlite3 base_test.rb | ... | ... |
... | ... | @@ -0,0 +1,182 @@ |
1 | +require 'rubygems' | |
2 | + | |
3 | +Gem::manage_gems | |
4 | + | |
5 | +require 'rake/rdoctask' | |
6 | +require 'rake/packagetask' | |
7 | +require 'rake/gempackagetask' | |
8 | +require 'rake/testtask' | |
9 | +require 'rake/contrib/rubyforgepublisher' | |
10 | + | |
11 | +PKG_NAME = 'acts_as_versioned' | |
12 | +PKG_VERSION = '0.3.1' | |
13 | +PKG_FILE_NAME = "#{PKG_NAME}-#{PKG_VERSION}" | |
14 | +PROD_HOST = "technoweenie@bidwell.textdrive.com" | |
15 | +RUBY_FORGE_PROJECT = 'ar-versioned' | |
16 | +RUBY_FORGE_USER = 'technoweenie' | |
17 | + | |
18 | +desc 'Default: run unit tests.' | |
19 | +task :default => :test | |
20 | + | |
21 | +desc 'Test the calculations plugin.' | |
22 | +Rake::TestTask.new(:test) do |t| | |
23 | + t.libs << 'lib' | |
24 | + t.pattern = 'test/**/*_test.rb' | |
25 | + t.verbose = true | |
26 | +end | |
27 | + | |
28 | +desc 'Generate documentation for the calculations plugin.' | |
29 | +Rake::RDocTask.new(:rdoc) do |rdoc| | |
30 | + rdoc.rdoc_dir = 'rdoc' | |
31 | + rdoc.title = "#{PKG_NAME} -- Simple versioning with active record models" | |
32 | + rdoc.options << '--line-numbers --inline-source' | |
33 | + rdoc.rdoc_files.include('README', 'CHANGELOG', 'RUNNING_UNIT_TESTS') | |
34 | + rdoc.rdoc_files.include('lib/**/*.rb') | |
35 | +end | |
36 | + | |
37 | +spec = Gem::Specification.new do |s| | |
38 | + s.name = PKG_NAME | |
39 | + s.version = PKG_VERSION | |
40 | + s.platform = Gem::Platform::RUBY | |
41 | + s.summary = "Simple versioning with active record models" | |
42 | + s.files = FileList["{lib,test}/**/*"].to_a + %w(README MIT-LICENSE CHANGELOG RUNNING_UNIT_TESTS) | |
43 | + s.files.delete "acts_as_versioned_plugin.sqlite.db" | |
44 | + s.files.delete "acts_as_versioned_plugin.sqlite3.db" | |
45 | + s.files.delete "test/debug.log" | |
46 | + s.require_path = 'lib' | |
47 | + s.autorequire = 'acts_as_versioned' | |
48 | + s.has_rdoc = true | |
49 | + s.test_files = Dir['test/**/*_test.rb'] | |
50 | + s.add_dependency 'activerecord', '>= 1.10.1' | |
51 | + s.add_dependency 'activesupport', '>= 1.1.1' | |
52 | + s.author = "Rick Olson" | |
53 | + s.email = "technoweenie@gmail.com" | |
54 | + s.homepage = "http://techno-weenie.net" | |
55 | +end | |
56 | + | |
57 | +Rake::GemPackageTask.new(spec) do |pkg| | |
58 | + pkg.need_tar = true | |
59 | +end | |
60 | + | |
61 | +desc "Publish the API documentation" | |
62 | +task :pdoc => [:rdoc] do | |
63 | + Rake::RubyForgePublisher.new(RUBY_FORGE_PROJECT, RUBY_FORGE_USER).upload | |
64 | +end | |
65 | + | |
66 | +desc 'Publish the gem and API docs' | |
67 | +task :publish => [:pdoc, :rubyforge_upload] | |
68 | + | |
69 | +desc "Publish the release files to RubyForge." | |
70 | +task :rubyforge_upload => :package do | |
71 | + files = %w(gem tgz).map { |ext| "pkg/#{PKG_FILE_NAME}.#{ext}" } | |
72 | + | |
73 | + if RUBY_FORGE_PROJECT then | |
74 | + require 'net/http' | |
75 | + require 'open-uri' | |
76 | + | |
77 | + project_uri = "http://rubyforge.org/projects/#{RUBY_FORGE_PROJECT}/" | |
78 | + project_data = open(project_uri) { |data| data.read } | |
79 | + group_id = project_data[/[?&]group_id=(\d+)/, 1] | |
80 | + raise "Couldn't get group id" unless group_id | |
81 | + | |
82 | + # This echos password to shell which is a bit sucky | |
83 | + if ENV["RUBY_FORGE_PASSWORD"] | |
84 | + password = ENV["RUBY_FORGE_PASSWORD"] | |
85 | + else | |
86 | + print "#{RUBY_FORGE_USER}@rubyforge.org's password: " | |
87 | + password = STDIN.gets.chomp | |
88 | + end | |
89 | + | |
90 | + login_response = Net::HTTP.start("rubyforge.org", 80) do |http| | |
91 | + data = [ | |
92 | + "login=1", | |
93 | + "form_loginname=#{RUBY_FORGE_USER}", | |
94 | + "form_pw=#{password}" | |
95 | + ].join("&") | |
96 | + http.post("/account/login.php", data) | |
97 | + end | |
98 | + | |
99 | + cookie = login_response["set-cookie"] | |
100 | + raise "Login failed" unless cookie | |
101 | + headers = { "Cookie" => cookie } | |
102 | + | |
103 | + release_uri = "http://rubyforge.org/frs/admin/?group_id=#{group_id}" | |
104 | + release_data = open(release_uri, headers) { |data| data.read } | |
105 | + package_id = release_data[/[?&]package_id=(\d+)/, 1] | |
106 | + raise "Couldn't get package id" unless package_id | |
107 | + | |
108 | + first_file = true | |
109 | + release_id = "" | |
110 | + | |
111 | + files.each do |filename| | |
112 | + basename = File.basename(filename) | |
113 | + file_ext = File.extname(filename) | |
114 | + file_data = File.open(filename, "rb") { |file| file.read } | |
115 | + | |
116 | + puts "Releasing #{basename}..." | |
117 | + | |
118 | + release_response = Net::HTTP.start("rubyforge.org", 80) do |http| | |
119 | + release_date = Time.now.strftime("%Y-%m-%d %H:%M") | |
120 | + type_map = { | |
121 | + ".zip" => "3000", | |
122 | + ".tgz" => "3110", | |
123 | + ".gz" => "3110", | |
124 | + ".gem" => "1400" | |
125 | + }; type_map.default = "9999" | |
126 | + type = type_map[file_ext] | |
127 | + boundary = "rubyqMY6QN9bp6e4kS21H4y0zxcvoor" | |
128 | + | |
129 | + query_hash = if first_file then | |
130 | + { | |
131 | + "group_id" => group_id, | |
132 | + "package_id" => package_id, | |
133 | + "release_name" => PKG_FILE_NAME, | |
134 | + "release_date" => release_date, | |
135 | + "type_id" => type, | |
136 | + "processor_id" => "8000", # Any | |
137 | + "release_notes" => "", | |
138 | + "release_changes" => "", | |
139 | + "preformatted" => "1", | |
140 | + "submit" => "1" | |
141 | + } | |
142 | + else | |
143 | + { | |
144 | + "group_id" => group_id, | |
145 | + "release_id" => release_id, | |
146 | + "package_id" => package_id, | |
147 | + "step2" => "1", | |
148 | + "type_id" => type, | |
149 | + "processor_id" => "8000", # Any | |
150 | + "submit" => "Add This File" | |
151 | + } | |
152 | + end | |
153 | + | |
154 | + query = "?" + query_hash.map do |(name, value)| | |
155 | + [name, URI.encode(value)].join("=") | |
156 | + end.join("&") | |
157 | + | |
158 | + data = [ | |
159 | + "--" + boundary, | |
160 | + "Content-Disposition: form-data; name=\"userfile\"; filename=\"#{basename}\"", | |
161 | + "Content-Type: application/octet-stream", | |
162 | + "Content-Transfer-Encoding: binary", | |
163 | + "", file_data, "" | |
164 | + ].join("\x0D\x0A") | |
165 | + | |
166 | + release_headers = headers.merge( | |
167 | + "Content-Type" => "multipart/form-data; boundary=#{boundary}" | |
168 | + ) | |
169 | + | |
170 | + target = first_file ? "/frs/admin/qrs.php" : "/frs/admin/editrelease.php" | |
171 | + http.post(target + query, data, release_headers) | |
172 | + end | |
173 | + | |
174 | + if first_file then | |
175 | + release_id = release_response.body[/release_id=(\d+)/, 1] | |
176 | + raise("Couldn't get release id") unless release_id | |
177 | + end | |
178 | + | |
179 | + first_file = false | |
180 | + end | |
181 | + end | |
182 | +end | |
0 | 183 | \ No newline at end of file | ... | ... |
vendor/plugins/acts_as_versioned/lib/acts_as_versioned.rb
0 → 100644
... | ... | @@ -0,0 +1,545 @@ |
1 | +# Copyright (c) 2005 Rick Olson | |
2 | +# | |
3 | +# Permission is hereby granted, free of charge, to any person obtaining | |
4 | +# a copy of this software and associated documentation files (the | |
5 | +# "Software"), to deal in the Software without restriction, including | |
6 | +# without limitation the rights to use, copy, modify, merge, publish, | |
7 | +# distribute, sublicense, and/or sell copies of the Software, and to | |
8 | +# permit persons to whom the Software is furnished to do so, subject to | |
9 | +# the following conditions: | |
10 | +# | |
11 | +# The above copyright notice and this permission notice shall be | |
12 | +# included in all copies or substantial portions of the Software. | |
13 | +# | |
14 | +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
15 | +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
16 | +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |
17 | +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | |
18 | +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | |
19 | +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | |
20 | +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
21 | + | |
22 | +module ActiveRecord #:nodoc: | |
23 | + module Acts #:nodoc: | |
24 | + # Specify this act if you want to save a copy of the row in a versioned table. This assumes there is a | |
25 | + # versioned table ready and that your model has a version field. This works with optimistic locking if the lock_version | |
26 | + # column is present as well. | |
27 | + # | |
28 | + # 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 | |
29 | + # your container for the changes to be reflected. In development mode this usually means restarting WEBrick. | |
30 | + # | |
31 | + # class Page < ActiveRecord::Base | |
32 | + # # assumes pages_versions table | |
33 | + # acts_as_versioned | |
34 | + # end | |
35 | + # | |
36 | + # Example: | |
37 | + # | |
38 | + # page = Page.create(:title => 'hello world!') | |
39 | + # page.version # => 1 | |
40 | + # | |
41 | + # page.title = 'hello world' | |
42 | + # page.save | |
43 | + # page.version # => 2 | |
44 | + # page.versions.size # => 2 | |
45 | + # | |
46 | + # page.revert_to(1) # using version number | |
47 | + # page.title # => 'hello world!' | |
48 | + # | |
49 | + # page.revert_to(page.versions.last) # using versioned instance | |
50 | + # page.title # => 'hello world' | |
51 | + # | |
52 | + # page.versions.earliest # efficient query to find the first version | |
53 | + # page.versions.latest # efficient query to find the most recently created version | |
54 | + # | |
55 | + # | |
56 | + # Simple Queries to page between versions | |
57 | + # | |
58 | + # page.versions.before(version) | |
59 | + # page.versions.after(version) | |
60 | + # | |
61 | + # Access the previous/next versions from the versioned model itself | |
62 | + # | |
63 | + # version = page.versions.latest | |
64 | + # version.previous # go back one version | |
65 | + # version.next # go forward one version | |
66 | + # | |
67 | + # See ActiveRecord::Acts::Versioned::ClassMethods#acts_as_versioned for configuration options | |
68 | + module Versioned | |
69 | + CALLBACKS = [:set_new_version, :save_version_on_create, :save_version?, :clear_changed_attributes] | |
70 | + def self.included(base) # :nodoc: | |
71 | + base.extend ClassMethods | |
72 | + end | |
73 | + | |
74 | + module ClassMethods | |
75 | + # == Configuration options | |
76 | + # | |
77 | + # * <tt>class_name</tt> - versioned model class name (default: PageVersion in the above example) | |
78 | + # * <tt>table_name</tt> - versioned model table name (default: page_versions in the above example) | |
79 | + # * <tt>foreign_key</tt> - foreign key used to relate the versioned model to the original model (default: page_id in the above example) | |
80 | + # * <tt>inheritance_column</tt> - name of the column to save the model's inheritance_column value for STI. (default: versioned_type) | |
81 | + # * <tt>version_column</tt> - name of the column in the model that keeps the version number (default: version) | |
82 | + # * <tt>sequence_name</tt> - name of the custom sequence to be used by the versioned model. | |
83 | + # * <tt>limit</tt> - number of revisions to keep, defaults to unlimited | |
84 | + # * <tt>if</tt> - symbol of method to check before saving a new version. If this method returns false, a new version is not saved. | |
85 | + # For finer control, pass either a Proc or modify Model#version_condition_met? | |
86 | + # | |
87 | + # acts_as_versioned :if => Proc.new { |auction| !auction.expired? } | |
88 | + # | |
89 | + # or... | |
90 | + # | |
91 | + # class Auction | |
92 | + # def version_condition_met? # totally bypasses the <tt>:if</tt> option | |
93 | + # !expired? | |
94 | + # end | |
95 | + # end | |
96 | + # | |
97 | + # * <tt>if_changed</tt> - Simple way of specifying attributes that are required to be changed before saving a model. This takes | |
98 | + # either a symbol or array of symbols. WARNING - This will attempt to overwrite any attribute setters you may have. | |
99 | + # Use this instead if you want to write your own attribute setters (and ignore if_changed): | |
100 | + # | |
101 | + # def name=(new_name) | |
102 | + # write_changed_attribute :name, new_name | |
103 | + # end | |
104 | + # | |
105 | + # * <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 | |
106 | + # to create an anonymous mixin: | |
107 | + # | |
108 | + # class Auction | |
109 | + # acts_as_versioned do | |
110 | + # def started? | |
111 | + # !started_at.nil? | |
112 | + # end | |
113 | + # end | |
114 | + # end | |
115 | + # | |
116 | + # or... | |
117 | + # | |
118 | + # module AuctionExtension | |
119 | + # def started? | |
120 | + # !started_at.nil? | |
121 | + # end | |
122 | + # end | |
123 | + # class Auction | |
124 | + # acts_as_versioned :extend => AuctionExtension | |
125 | + # end | |
126 | + # | |
127 | + # Example code: | |
128 | + # | |
129 | + # @auction = Auction.find(1) | |
130 | + # @auction.started? | |
131 | + # @auction.versions.first.started? | |
132 | + # | |
133 | + # == Database Schema | |
134 | + # | |
135 | + # The model that you're versioning needs to have a 'version' attribute. The model is versioned | |
136 | + # into a table called #{model}_versions where the model name is singlular. The _versions table should | |
137 | + # contain all the fields you want versioned, the same version column, and a #{model}_id foreign key field. | |
138 | + # | |
139 | + # A lock_version field is also accepted if your model uses Optimistic Locking. If your table uses Single Table inheritance, | |
140 | + # then that field is reflected in the versioned model as 'versioned_type' by default. | |
141 | + # | |
142 | + # Acts_as_versioned comes prepared with the ActiveRecord::Acts::Versioned::ActMethods::ClassMethods#create_versioned_table | |
143 | + # method, perfect for a migration. It will also create the version column if the main model does not already have it. | |
144 | + # | |
145 | + # class AddVersions < ActiveRecord::Migration | |
146 | + # def self.up | |
147 | + # # create_versioned_table takes the same options hash | |
148 | + # # that create_table does | |
149 | + # Post.create_versioned_table | |
150 | + # end | |
151 | + # | |
152 | + # def self.down | |
153 | + # Post.drop_versioned_table | |
154 | + # end | |
155 | + # end | |
156 | + # | |
157 | + # == Changing What Fields Are Versioned | |
158 | + # | |
159 | + # By default, acts_as_versioned will version all but these fields: | |
160 | + # | |
161 | + # [self.primary_key, inheritance_column, 'version', 'lock_version', versioned_inheritance_column] | |
162 | + # | |
163 | + # You can add or change those by modifying #non_versioned_columns. Note that this takes strings and not symbols. | |
164 | + # | |
165 | + # class Post < ActiveRecord::Base | |
166 | + # acts_as_versioned | |
167 | + # self.non_versioned_columns << 'comments_count' | |
168 | + # end | |
169 | + # | |
170 | + def acts_as_versioned(options = {}, &extension) | |
171 | + # don't allow multiple calls | |
172 | + return if self.included_modules.include?(ActiveRecord::Acts::Versioned::ActMethods) | |
173 | + | |
174 | + send :include, ActiveRecord::Acts::Versioned::ActMethods | |
175 | + | |
176 | + cattr_accessor :versioned_class_name, :versioned_foreign_key, :versioned_table_name, :versioned_inheritance_column, | |
177 | + :version_column, :max_version_limit, :track_changed_attributes, :version_condition, :version_sequence_name, :non_versioned_columns, | |
178 | + :version_association_options | |
179 | + | |
180 | + # legacy | |
181 | + alias_method :non_versioned_fields, :non_versioned_columns | |
182 | + alias_method :non_versioned_fields=, :non_versioned_columns= | |
183 | + | |
184 | + class << self | |
185 | + alias_method :non_versioned_fields, :non_versioned_columns | |
186 | + alias_method :non_versioned_fields=, :non_versioned_columns= | |
187 | + end | |
188 | + | |
189 | + send :attr_accessor, :changed_attributes | |
190 | + | |
191 | + self.versioned_class_name = options[:class_name] || "Version" | |
192 | + self.versioned_foreign_key = options[:foreign_key] || self.to_s.foreign_key | |
193 | + self.versioned_table_name = options[:table_name] || "#{table_name_prefix}#{base_class.name.demodulize.underscore}_versions#{table_name_suffix}" | |
194 | + self.versioned_inheritance_column = options[:inheritance_column] || "versioned_#{inheritance_column}" | |
195 | + self.version_column = options[:version_column] || 'version' | |
196 | + self.version_sequence_name = options[:sequence_name] | |
197 | + self.max_version_limit = options[:limit].to_i | |
198 | + self.version_condition = options[:if] || true | |
199 | + self.non_versioned_columns = [self.primary_key, inheritance_column, 'version', 'lock_version', versioned_inheritance_column] | |
200 | + self.version_association_options = { | |
201 | + :class_name => "#{self.to_s}::#{versioned_class_name}", | |
202 | + :foreign_key => versioned_foreign_key, | |
203 | + :order => 'version', | |
204 | + :dependent => :delete_all | |
205 | + }.merge(options[:association_options] || {}) | |
206 | + | |
207 | + if block_given? | |
208 | + extension_module_name = "#{versioned_class_name}Extension" | |
209 | + silence_warnings do | |
210 | + self.const_set(extension_module_name, Module.new(&extension)) | |
211 | + end | |
212 | + | |
213 | + options[:extend] = self.const_get(extension_module_name) | |
214 | + end | |
215 | + | |
216 | + class_eval do | |
217 | + has_many :versions, version_association_options do | |
218 | + # finds earliest version of this record | |
219 | + def earliest | |
220 | + @earliest ||= find(:first) | |
221 | + end | |
222 | + | |
223 | + # find latest version of this record | |
224 | + def latest | |
225 | + @latest ||= find(:first, :order => 'version desc') | |
226 | + end | |
227 | + end | |
228 | + before_save :set_new_version | |
229 | + after_create :save_version_on_create | |
230 | + after_update :save_version | |
231 | + after_save :clear_old_versions | |
232 | + after_save :clear_changed_attributes | |
233 | + | |
234 | + unless options[:if_changed].nil? | |
235 | + self.track_changed_attributes = true | |
236 | + options[:if_changed] = [options[:if_changed]] unless options[:if_changed].is_a?(Array) | |
237 | + options[:if_changed].each do |attr_name| | |
238 | + define_method("#{attr_name}=") do |value| | |
239 | + write_changed_attribute attr_name, value | |
240 | + end | |
241 | + end | |
242 | + end | |
243 | + | |
244 | + include options[:extend] if options[:extend].is_a?(Module) | |
245 | + end | |
246 | + | |
247 | + # create the dynamic versioned model | |
248 | + const_set(versioned_class_name, Class.new(ActiveRecord::Base)).class_eval do | |
249 | + def self.reloadable? ; false ; end | |
250 | + # find first version before the given version | |
251 | + def self.before(version) | |
252 | + find :first, :order => 'version desc', | |
253 | + :conditions => ["#{original_class.versioned_foreign_key} = ? and version < ?", version.send(original_class.versioned_foreign_key), version.version] | |
254 | + end | |
255 | + | |
256 | + # find first version after the given version. | |
257 | + def self.after(version) | |
258 | + find :first, :order => 'version', | |
259 | + :conditions => ["#{original_class.versioned_foreign_key} = ? and version > ?", version.send(original_class.versioned_foreign_key), version.version] | |
260 | + end | |
261 | + | |
262 | + def previous | |
263 | + self.class.before(self) | |
264 | + end | |
265 | + | |
266 | + def next | |
267 | + self.class.after(self) | |
268 | + end | |
269 | + end | |
270 | + | |
271 | + versioned_class.cattr_accessor :original_class | |
272 | + versioned_class.original_class = self | |
273 | + versioned_class.set_table_name versioned_table_name | |
274 | + versioned_class.belongs_to self.to_s.demodulize.underscore.to_sym, | |
275 | + :class_name => "::#{self.to_s}", | |
276 | + :foreign_key => versioned_foreign_key | |
277 | + versioned_class.send :include, options[:extend] if options[:extend].is_a?(Module) | |
278 | + versioned_class.set_sequence_name version_sequence_name if version_sequence_name | |
279 | + end | |
280 | + end | |
281 | + | |
282 | + module ActMethods | |
283 | + def self.included(base) # :nodoc: | |
284 | + base.extend ClassMethods | |
285 | + end | |
286 | + | |
287 | + # Saves a version of the model if applicable | |
288 | + def save_version | |
289 | + save_version_on_create if save_version? | |
290 | + end | |
291 | + | |
292 | + # Saves a version of the model in the versioned table. This is called in the after_save callback by default | |
293 | + def save_version_on_create | |
294 | + rev = self.class.versioned_class.new | |
295 | + self.clone_versioned_model(self, rev) | |
296 | + rev.version = send(self.class.version_column) | |
297 | + rev.send("#{self.class.versioned_foreign_key}=", self.id) | |
298 | + rev.save | |
299 | + end | |
300 | + | |
301 | + # Clears old revisions if a limit is set with the :limit option in <tt>acts_as_versioned</tt>. | |
302 | + # Override this method to set your own criteria for clearing old versions. | |
303 | + def clear_old_versions | |
304 | + return if self.class.max_version_limit == 0 | |
305 | + excess_baggage = send(self.class.version_column).to_i - self.class.max_version_limit | |
306 | + if excess_baggage > 0 | |
307 | + sql = "DELETE FROM #{self.class.versioned_table_name} WHERE version <= #{excess_baggage} AND #{self.class.versioned_foreign_key} = #{self.id}" | |
308 | + self.class.versioned_class.connection.execute sql | |
309 | + end | |
310 | + end | |
311 | + | |
312 | + # Reverts a model to a given version. Takes either a version number or an instance of the versioned model | |
313 | + def revert_to(version) | |
314 | + if version.is_a?(self.class.versioned_class) | |
315 | + return false unless version.send(self.class.versioned_foreign_key) == self.id and !version.new_record? | |
316 | + else | |
317 | + return false unless version = versions.find_by_version(version) | |
318 | + end | |
319 | + self.clone_versioned_model(version, self) | |
320 | + self.send("#{self.class.version_column}=", version.version) | |
321 | + true | |
322 | + end | |
323 | + | |
324 | + # Reverts a model to a given version and saves the model. | |
325 | + # Takes either a version number or an instance of the versioned model | |
326 | + def revert_to!(version) | |
327 | + revert_to(version) ? save_without_revision : false | |
328 | + end | |
329 | + | |
330 | + # Temporarily turns off Optimistic Locking while saving. Used when reverting so that a new version is not created. | |
331 | + def save_without_revision | |
332 | + save_without_revision! | |
333 | + true | |
334 | + rescue | |
335 | + false | |
336 | + end | |
337 | + | |
338 | + def save_without_revision! | |
339 | + without_locking do | |
340 | + without_revision do | |
341 | + save! | |
342 | + end | |
343 | + end | |
344 | + end | |
345 | + | |
346 | + # Returns an array of attribute keys that are versioned. See non_versioned_columns | |
347 | + def versioned_attributes | |
348 | + self.attributes.keys.select { |k| !self.class.non_versioned_columns.include?(k) } | |
349 | + end | |
350 | + | |
351 | + # If called with no parameters, gets whether the current model has changed and needs to be versioned. | |
352 | + # If called with a single parameter, gets whether the parameter has changed. | |
353 | + def changed?(attr_name = nil) | |
354 | + attr_name.nil? ? | |
355 | + (!self.class.track_changed_attributes || (changed_attributes && changed_attributes.length > 0)) : | |
356 | + (changed_attributes && changed_attributes.include?(attr_name.to_s)) | |
357 | + end | |
358 | + | |
359 | + # keep old dirty? method | |
360 | + alias_method :dirty?, :changed? | |
361 | + | |
362 | + # Clones a model. Used when saving a new version or reverting a model's version. | |
363 | + def clone_versioned_model(orig_model, new_model) | |
364 | + self.versioned_attributes.each do |key| | |
365 | + new_model.send("#{key}=", orig_model.send(key)) if orig_model.has_attribute?(key) | |
366 | + end | |
367 | + | |
368 | + if orig_model.is_a?(self.class.versioned_class) | |
369 | + new_model[new_model.class.inheritance_column] = orig_model[self.class.versioned_inheritance_column] | |
370 | + elsif new_model.is_a?(self.class.versioned_class) | |
371 | + new_model[self.class.versioned_inheritance_column] = orig_model[orig_model.class.inheritance_column] | |
372 | + end | |
373 | + end | |
374 | + | |
375 | + # Checks whether a new version shall be saved or not. Calls <tt>version_condition_met?</tt> and <tt>changed?</tt>. | |
376 | + def save_version? | |
377 | + version_condition_met? && changed? | |
378 | + end | |
379 | + | |
380 | + # Checks condition set in the :if option to check whether a revision should be created or not. Override this for | |
381 | + # custom version condition checking. | |
382 | + def version_condition_met? | |
383 | + case | |
384 | + when version_condition.is_a?(Symbol) | |
385 | + send(version_condition) | |
386 | + when version_condition.respond_to?(:call) && (version_condition.arity == 1 || version_condition.arity == -1) | |
387 | + version_condition.call(self) | |
388 | + else | |
389 | + version_condition | |
390 | + end | |
391 | + end | |
392 | + | |
393 | + # Executes the block with the versioning callbacks disabled. | |
394 | + # | |
395 | + # @foo.without_revision do | |
396 | + # @foo.save | |
397 | + # end | |
398 | + # | |
399 | + def without_revision(&block) | |
400 | + self.class.without_revision(&block) | |
401 | + end | |
402 | + | |
403 | + # Turns off optimistic locking for the duration of the block | |
404 | + # | |
405 | + # @foo.without_locking do | |
406 | + # @foo.save | |
407 | + # end | |
408 | + # | |
409 | + def without_locking(&block) | |
410 | + self.class.without_locking(&block) | |
411 | + end | |
412 | + | |
413 | + def empty_callback() end #:nodoc: | |
414 | + | |
415 | + protected | |
416 | + # sets the new version before saving, unless you're using optimistic locking. In that case, let it take care of the version. | |
417 | + def set_new_version | |
418 | + self.send("#{self.class.version_column}=", self.next_version) if new_record? || (!locking_enabled? && save_version?) | |
419 | + end | |
420 | + | |
421 | + # Gets the next available version for the current record, or 1 for a new record | |
422 | + def next_version | |
423 | + return 1 if new_record? | |
424 | + (versions.calculate(:max, :version) || 0) + 1 | |
425 | + end | |
426 | + | |
427 | + # clears current changed attributes. Called after save. | |
428 | + def clear_changed_attributes | |
429 | + self.changed_attributes = [] | |
430 | + end | |
431 | + | |
432 | + def write_changed_attribute(attr_name, attr_value) | |
433 | + # Convert to db type for comparison. Avoids failing Float<=>String comparisons. | |
434 | + attr_value_for_db = self.class.columns_hash[attr_name.to_s].type_cast(attr_value) | |
435 | + (self.changed_attributes ||= []) << attr_name.to_s unless self.changed?(attr_name) || self.send(attr_name) == attr_value_for_db | |
436 | + write_attribute(attr_name, attr_value_for_db) | |
437 | + end | |
438 | + | |
439 | + module ClassMethods | |
440 | + # Finds a specific version of a specific row of this model | |
441 | + def find_version(id, version) | |
442 | + find_versions(id, | |
443 | + :conditions => ["#{versioned_foreign_key} = ? AND version = ?", id, version], | |
444 | + :limit => 1).first | |
445 | + end | |
446 | + | |
447 | + # Finds versions of a specific model. Takes an options hash like <tt>find</tt> | |
448 | + def find_versions(id, options = {}) | |
449 | + versioned_class.find :all, { | |
450 | + :conditions => ["#{versioned_foreign_key} = ?", id], | |
451 | + :order => 'version' }.merge(options) | |
452 | + end | |
453 | + | |
454 | + # Returns an array of columns that are versioned. See non_versioned_columns | |
455 | + def versioned_columns | |
456 | + self.columns.select { |c| !non_versioned_columns.include?(c.name) } | |
457 | + end | |
458 | + | |
459 | + # Returns an instance of the dynamic versioned model | |
460 | + def versioned_class | |
461 | + const_get versioned_class_name | |
462 | + end | |
463 | + | |
464 | + # Rake migration task to create the versioned table using options passed to acts_as_versioned | |
465 | + def create_versioned_table(create_table_options = {}) | |
466 | + # create version column in main table if it does not exist | |
467 | + if !self.content_columns.find { |c| %w(version lock_version).include? c.name } | |
468 | + self.connection.add_column table_name, :version, :integer | |
469 | + end | |
470 | + | |
471 | + self.connection.create_table(versioned_table_name, create_table_options) do |t| | |
472 | + t.column versioned_foreign_key, :integer | |
473 | + t.column :version, :integer | |
474 | + end | |
475 | + | |
476 | + updated_col = nil | |
477 | + self.versioned_columns.each do |col| | |
478 | + updated_col = col if !updated_col && %(updated_at updated_on).include?(col.name) | |
479 | + self.connection.add_column versioned_table_name, col.name, col.type, | |
480 | + :limit => col.limit, | |
481 | + :default => col.default, | |
482 | + :scale => col.scale, | |
483 | + :precision => col.precision | |
484 | + end | |
485 | + | |
486 | + if type_col = self.columns_hash[inheritance_column] | |
487 | + self.connection.add_column versioned_table_name, versioned_inheritance_column, type_col.type, | |
488 | + :limit => type_col.limit, | |
489 | + :default => type_col.default, | |
490 | + :scale => type_col.scale, | |
491 | + :precision => type_col.precision | |
492 | + end | |
493 | + | |
494 | + if updated_col.nil? | |
495 | + self.connection.add_column versioned_table_name, :updated_at, :timestamp | |
496 | + end | |
497 | + end | |
498 | + | |
499 | + # Rake migration task to drop the versioned table | |
500 | + def drop_versioned_table | |
501 | + self.connection.drop_table versioned_table_name | |
502 | + end | |
503 | + | |
504 | + # Executes the block with the versioning callbacks disabled. | |
505 | + # | |
506 | + # Foo.without_revision do | |
507 | + # @foo.save | |
508 | + # end | |
509 | + # | |
510 | + def without_revision(&block) | |
511 | + class_eval do | |
512 | + CALLBACKS.each do |attr_name| | |
513 | + alias_method "orig_#{attr_name}".to_sym, attr_name | |
514 | + alias_method attr_name, :empty_callback | |
515 | + end | |
516 | + end | |
517 | + block.call | |
518 | + ensure | |
519 | + class_eval do | |
520 | + CALLBACKS.each do |attr_name| | |
521 | + alias_method attr_name, "orig_#{attr_name}".to_sym | |
522 | + end | |
523 | + end | |
524 | + end | |
525 | + | |
526 | + # Turns off optimistic locking for the duration of the block | |
527 | + # | |
528 | + # Foo.without_locking do | |
529 | + # @foo.save | |
530 | + # end | |
531 | + # | |
532 | + def without_locking(&block) | |
533 | + current = ActiveRecord::Base.lock_optimistically | |
534 | + ActiveRecord::Base.lock_optimistically = false if current | |
535 | + result = block.call | |
536 | + ActiveRecord::Base.lock_optimistically = true if current | |
537 | + result | |
538 | + end | |
539 | + end | |
540 | + end | |
541 | + end | |
542 | + end | |
543 | +end | |
544 | + | |
545 | +ActiveRecord::Base.send :include, ActiveRecord::Acts::Versioned | |
0 | 546 | \ No newline at end of file | ... | ... |
... | ... | @@ -0,0 +1,41 @@ |
1 | +$:.unshift(File.dirname(__FILE__) + '/../../../rails/activesupport/lib') | |
2 | +$:.unshift(File.dirname(__FILE__) + '/../../../rails/activerecord/lib') | |
3 | +$:.unshift(File.dirname(__FILE__) + '/../lib') | |
4 | +require 'test/unit' | |
5 | +begin | |
6 | + require 'active_support' | |
7 | + require 'active_record' | |
8 | + require 'active_record/fixtures' | |
9 | +rescue LoadError | |
10 | + require 'rubygems' | |
11 | + retry | |
12 | +end | |
13 | +require 'acts_as_versioned' | |
14 | + | |
15 | +config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml')) | |
16 | +ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log") | |
17 | +ActiveRecord::Base.configurations = {'test' => config[ENV['DB'] || 'sqlite']} | |
18 | +ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations['test']) | |
19 | + | |
20 | +load(File.dirname(__FILE__) + "/schema.rb") | |
21 | + | |
22 | +# set up custom sequence on widget_versions for DBs that support sequences | |
23 | +if ENV['DB'] == 'postgresql' | |
24 | + ActiveRecord::Base.connection.execute "DROP SEQUENCE widgets_seq;" rescue nil | |
25 | + ActiveRecord::Base.connection.remove_column :widget_versions, :id | |
26 | + ActiveRecord::Base.connection.execute "CREATE SEQUENCE widgets_seq START 101;" | |
27 | + ActiveRecord::Base.connection.execute "ALTER TABLE widget_versions ADD COLUMN id INTEGER PRIMARY KEY DEFAULT nextval('widgets_seq');" | |
28 | +end | |
29 | + | |
30 | +Test::Unit::TestCase.fixture_path = File.dirname(__FILE__) + "/fixtures/" | |
31 | +$:.unshift(Test::Unit::TestCase.fixture_path) | |
32 | + | |
33 | +class Test::Unit::TestCase #:nodoc: | |
34 | + # Turn off transactional fixtures if you're working with MyISAM tables in MySQL | |
35 | + self.use_transactional_fixtures = true | |
36 | + | |
37 | + # Instantiated fixtures are slow, but give you @david where you otherwise would need people(:david) | |
38 | + self.use_instantiated_fixtures = false | |
39 | + | |
40 | + # Add more helper methods to be used by all tests here... | |
41 | +end | |
0 | 42 | \ No newline at end of file | ... | ... |
... | ... | @@ -0,0 +1,18 @@ |
1 | +sqlite: | |
2 | + :adapter: sqlite | |
3 | + :dbfile: acts_as_versioned_plugin.sqlite.db | |
4 | +sqlite3: | |
5 | + :adapter: sqlite3 | |
6 | + :dbfile: acts_as_versioned_plugin.sqlite3.db | |
7 | +postgresql: | |
8 | + :adapter: postgresql | |
9 | + :username: postgres | |
10 | + :password: postgres | |
11 | + :database: acts_as_versioned_plugin_test | |
12 | + :min_messages: ERROR | |
13 | +mysql: | |
14 | + :adapter: mysql | |
15 | + :host: localhost | |
16 | + :username: rails | |
17 | + :password: | |
18 | + :database: acts_as_versioned_plugin_test | |
0 | 19 | \ No newline at end of file | ... | ... |
vendor/plugins/acts_as_versioned/test/fixtures/authors.yml
0 → 100644
vendor/plugins/acts_as_versioned/test/fixtures/landmark.rb
0 → 100644
vendor/plugins/acts_as_versioned/test/fixtures/landmark_versions.yml
0 → 100644
vendor/plugins/acts_as_versioned/test/fixtures/landmarks.yml
0 → 100644
vendor/plugins/acts_as_versioned/test/fixtures/locked_pages.yml
0 → 100644
vendor/plugins/acts_as_versioned/test/fixtures/locked_pages_revisions.yml
0 → 100644
... | ... | @@ -0,0 +1,27 @@ |
1 | +welcome_1: | |
2 | + id: 1 | |
3 | + page_id: 1 | |
4 | + title: Welcome to the weblg | |
5 | + version: 23 | |
6 | + version_type: LockedPage | |
7 | + | |
8 | +welcome_2: | |
9 | + id: 2 | |
10 | + page_id: 1 | |
11 | + title: Welcome to the weblog | |
12 | + version: 24 | |
13 | + version_type: LockedPage | |
14 | + | |
15 | +thinking_1: | |
16 | + id: 3 | |
17 | + page_id: 2 | |
18 | + title: So I was thinking!!! | |
19 | + version: 23 | |
20 | + version_type: SpecialLockedPage | |
21 | + | |
22 | +thinking_2: | |
23 | + id: 4 | |
24 | + page_id: 2 | |
25 | + title: So I was thinking | |
26 | + version: 24 | |
27 | + version_type: SpecialLockedPage | ... | ... |
vendor/plugins/acts_as_versioned/test/fixtures/migrations/1_add_versioned_tables.rb
0 → 100644
... | ... | @@ -0,0 +1,15 @@ |
1 | +class AddVersionedTables < ActiveRecord::Migration | |
2 | + def self.up | |
3 | + create_table("things") do |t| | |
4 | + t.column :title, :text | |
5 | + t.column :price, :decimal, :precision => 7, :scale => 2 | |
6 | + t.column :type, :string | |
7 | + end | |
8 | + Thing.create_versioned_table | |
9 | + end | |
10 | + | |
11 | + def self.down | |
12 | + Thing.drop_versioned_table | |
13 | + drop_table "things" rescue nil | |
14 | + end | |
15 | +end | |
0 | 16 | \ No newline at end of file | ... | ... |
... | ... | @@ -0,0 +1,43 @@ |
1 | +class Page < ActiveRecord::Base | |
2 | + belongs_to :author | |
3 | + has_many :authors, :through => :versions, :order => 'name' | |
4 | + belongs_to :revisor, :class_name => 'Author' | |
5 | + has_many :revisors, :class_name => 'Author', :through => :versions, :order => 'name' | |
6 | + acts_as_versioned :if => :feeling_good? do | |
7 | + def self.included(base) | |
8 | + base.cattr_accessor :feeling_good | |
9 | + base.feeling_good = true | |
10 | + base.belongs_to :author | |
11 | + base.belongs_to :revisor, :class_name => 'Author' | |
12 | + end | |
13 | + | |
14 | + def feeling_good? | |
15 | + @@feeling_good == true | |
16 | + end | |
17 | + end | |
18 | +end | |
19 | + | |
20 | +module LockedPageExtension | |
21 | + def hello_world | |
22 | + 'hello_world' | |
23 | + end | |
24 | +end | |
25 | + | |
26 | +class LockedPage < ActiveRecord::Base | |
27 | + acts_as_versioned \ | |
28 | + :inheritance_column => :version_type, | |
29 | + :foreign_key => :page_id, | |
30 | + :table_name => :locked_pages_revisions, | |
31 | + :class_name => 'LockedPageRevision', | |
32 | + :version_column => :lock_version, | |
33 | + :limit => 2, | |
34 | + :if_changed => :title, | |
35 | + :extend => LockedPageExtension | |
36 | +end | |
37 | + | |
38 | +class SpecialLockedPage < LockedPage | |
39 | +end | |
40 | + | |
41 | +class Author < ActiveRecord::Base | |
42 | + has_many :pages | |
43 | +end | |
0 | 44 | \ No newline at end of file | ... | ... |
vendor/plugins/acts_as_versioned/test/fixtures/page_versions.yml
0 → 100644
... | ... | @@ -0,0 +1,16 @@ |
1 | +welcome_2: | |
2 | + id: 1 | |
3 | + page_id: 1 | |
4 | + title: Welcome to the weblog | |
5 | + body: Such a lovely day | |
6 | + version: 24 | |
7 | + author_id: 1 | |
8 | + revisor_id: 1 | |
9 | +welcome_1: | |
10 | + id: 2 | |
11 | + page_id: 1 | |
12 | + title: Welcome to the weblg | |
13 | + body: Such a lovely day | |
14 | + version: 23 | |
15 | + author_id: 2 | |
16 | + revisor_id: 2 | ... | ... |
vendor/plugins/acts_as_versioned/test/fixtures/pages.yml
0 → 100644
vendor/plugins/acts_as_versioned/test/fixtures/widget.rb
0 → 100644
... | ... | @@ -0,0 +1,41 @@ |
1 | +require File.join(File.dirname(__FILE__), 'abstract_unit') | |
2 | + | |
3 | +if ActiveRecord::Base.connection.supports_migrations? | |
4 | + class Thing < ActiveRecord::Base | |
5 | + attr_accessor :version | |
6 | + acts_as_versioned | |
7 | + end | |
8 | + | |
9 | + class MigrationTest < Test::Unit::TestCase | |
10 | + self.use_transactional_fixtures = false | |
11 | + def teardown | |
12 | + ActiveRecord::Base.connection.initialize_schema_information | |
13 | + ActiveRecord::Base.connection.update "UPDATE schema_info SET version = 0" | |
14 | + | |
15 | + Thing.connection.drop_table "things" rescue nil | |
16 | + Thing.connection.drop_table "thing_versions" rescue nil | |
17 | + Thing.reset_column_information | |
18 | + end | |
19 | + | |
20 | + def test_versioned_migration | |
21 | + assert_raises(ActiveRecord::StatementInvalid) { Thing.create :title => 'blah blah' } | |
22 | + # take 'er up | |
23 | + ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/') | |
24 | + t = Thing.create :title => 'blah blah', :price => 123.45, :type => 'Thing' | |
25 | + assert_equal 1, t.versions.size | |
26 | + | |
27 | + # check that the price column has remembered its value correctly | |
28 | + assert_equal t.price, t.versions.first.price | |
29 | + assert_equal t.title, t.versions.first.title | |
30 | + assert_equal t[:type], t.versions.first[:type] | |
31 | + | |
32 | + # make sure that the precision of the price column has been preserved | |
33 | + assert_equal 7, Thing::Version.columns.find{|c| c.name == "price"}.precision | |
34 | + assert_equal 2, Thing::Version.columns.find{|c| c.name == "price"}.scale | |
35 | + | |
36 | + # now lets take 'er back down | |
37 | + ActiveRecord::Migrator.down(File.dirname(__FILE__) + '/fixtures/migrations/') | |
38 | + assert_raises(ActiveRecord::StatementInvalid) { Thing.create :title => 'blah blah' } | |
39 | + end | |
40 | + end | |
41 | +end | ... | ... |
... | ... | @@ -0,0 +1,68 @@ |
1 | +ActiveRecord::Schema.define(:version => 0) do | |
2 | + create_table :pages, :force => true do |t| | |
3 | + t.column :version, :integer | |
4 | + t.column :title, :string, :limit => 255 | |
5 | + t.column :body, :text | |
6 | + t.column :updated_on, :datetime | |
7 | + t.column :author_id, :integer | |
8 | + t.column :revisor_id, :integer | |
9 | + end | |
10 | + | |
11 | + create_table :page_versions, :force => true do |t| | |
12 | + t.column :page_id, :integer | |
13 | + t.column :version, :integer | |
14 | + t.column :title, :string, :limit => 255 | |
15 | + t.column :body, :text | |
16 | + t.column :updated_on, :datetime | |
17 | + t.column :author_id, :integer | |
18 | + t.column :revisor_id, :integer | |
19 | + end | |
20 | + | |
21 | + create_table :authors, :force => true do |t| | |
22 | + t.column :page_id, :integer | |
23 | + t.column :name, :string | |
24 | + end | |
25 | + | |
26 | + create_table :locked_pages, :force => true do |t| | |
27 | + t.column :lock_version, :integer | |
28 | + t.column :title, :string, :limit => 255 | |
29 | + t.column :type, :string, :limit => 255 | |
30 | + end | |
31 | + | |
32 | + create_table :locked_pages_revisions, :force => true do |t| | |
33 | + t.column :page_id, :integer | |
34 | + t.column :version, :integer | |
35 | + t.column :title, :string, :limit => 255 | |
36 | + t.column :version_type, :string, :limit => 255 | |
37 | + t.column :updated_at, :datetime | |
38 | + end | |
39 | + | |
40 | + create_table :widgets, :force => true do |t| | |
41 | + t.column :name, :string, :limit => 50 | |
42 | + t.column :foo, :string | |
43 | + t.column :version, :integer | |
44 | + t.column :updated_at, :datetime | |
45 | + end | |
46 | + | |
47 | + create_table :widget_versions, :force => true do |t| | |
48 | + t.column :widget_id, :integer | |
49 | + t.column :name, :string, :limit => 50 | |
50 | + t.column :version, :integer | |
51 | + t.column :updated_at, :datetime | |
52 | + end | |
53 | + | |
54 | + create_table :landmarks, :force => true do |t| | |
55 | + t.column :name, :string | |
56 | + t.column :latitude, :float | |
57 | + t.column :longitude, :float | |
58 | + t.column :version, :integer | |
59 | + end | |
60 | + | |
61 | + create_table :landmark_versions, :force => true do |t| | |
62 | + t.column :landmark_id, :integer | |
63 | + t.column :name, :string | |
64 | + t.column :latitude, :float | |
65 | + t.column :longitude, :float | |
66 | + t.column :version, :integer | |
67 | + end | |
68 | +end | ... | ... |
... | ... | @@ -0,0 +1,328 @@ |
1 | +require File.join(File.dirname(__FILE__), 'abstract_unit') | |
2 | +require File.join(File.dirname(__FILE__), 'fixtures/page') | |
3 | +require File.join(File.dirname(__FILE__), 'fixtures/widget') | |
4 | + | |
5 | +class VersionedTest < Test::Unit::TestCase | |
6 | + fixtures :pages, :page_versions, :locked_pages, :locked_pages_revisions, :authors, :landmarks, :landmark_versions | |
7 | + set_fixture_class :page_versions => Page::Version | |
8 | + | |
9 | + def test_saves_versioned_copy | |
10 | + p = Page.create! :title => 'first title', :body => 'first body' | |
11 | + assert !p.new_record? | |
12 | + assert_equal 1, p.versions.size | |
13 | + assert_equal 1, p.version | |
14 | + assert_instance_of Page.versioned_class, p.versions.first | |
15 | + end | |
16 | + | |
17 | + def test_saves_without_revision | |
18 | + p = pages(:welcome) | |
19 | + old_versions = p.versions.count | |
20 | + | |
21 | + p.save_without_revision | |
22 | + | |
23 | + p.without_revision do | |
24 | + p.update_attributes :title => 'changed' | |
25 | + end | |
26 | + | |
27 | + assert_equal old_versions, p.versions.count | |
28 | + end | |
29 | + | |
30 | + def test_rollback_with_version_number | |
31 | + p = pages(:welcome) | |
32 | + assert_equal 24, p.version | |
33 | + assert_equal 'Welcome to the weblog', p.title | |
34 | + | |
35 | + assert p.revert_to!(p.versions.first.version), "Couldn't revert to 23" | |
36 | + assert_equal 23, p.version | |
37 | + assert_equal 'Welcome to the weblg', p.title | |
38 | + end | |
39 | + | |
40 | + def test_versioned_class_name | |
41 | + assert_equal 'Version', Page.versioned_class_name | |
42 | + assert_equal 'LockedPageRevision', LockedPage.versioned_class_name | |
43 | + end | |
44 | + | |
45 | + def test_versioned_class | |
46 | + assert_equal Page::Version, Page.versioned_class | |
47 | + assert_equal LockedPage::LockedPageRevision, LockedPage.versioned_class | |
48 | + end | |
49 | + | |
50 | + def test_special_methods | |
51 | + assert_nothing_raised { pages(:welcome).feeling_good? } | |
52 | + assert_nothing_raised { pages(:welcome).versions.first.feeling_good? } | |
53 | + assert_nothing_raised { locked_pages(:welcome).hello_world } | |
54 | + assert_nothing_raised { locked_pages(:welcome).versions.first.hello_world } | |
55 | + end | |
56 | + | |
57 | + def test_rollback_with_version_class | |
58 | + p = pages(:welcome) | |
59 | + assert_equal 24, p.version | |
60 | + assert_equal 'Welcome to the weblog', p.title | |
61 | + | |
62 | + assert p.revert_to!(p.versions.first), "Couldn't revert to 23" | |
63 | + assert_equal 23, p.version | |
64 | + assert_equal 'Welcome to the weblg', p.title | |
65 | + end | |
66 | + | |
67 | + def test_rollback_fails_with_invalid_revision | |
68 | + p = locked_pages(:welcome) | |
69 | + assert !p.revert_to!(locked_pages(:thinking)) | |
70 | + end | |
71 | + | |
72 | + def test_saves_versioned_copy_with_options | |
73 | + p = LockedPage.create! :title => 'first title' | |
74 | + assert !p.new_record? | |
75 | + assert_equal 1, p.versions.size | |
76 | + assert_instance_of LockedPage.versioned_class, p.versions.first | |
77 | + end | |
78 | + | |
79 | + def test_rollback_with_version_number_with_options | |
80 | + p = locked_pages(:welcome) | |
81 | + assert_equal 'Welcome to the weblog', p.title | |
82 | + assert_equal 'LockedPage', p.versions.first.version_type | |
83 | + | |
84 | + assert p.revert_to!(p.versions.first.version), "Couldn't revert to 23" | |
85 | + assert_equal 'Welcome to the weblg', p.title | |
86 | + assert_equal 'LockedPage', p.versions.first.version_type | |
87 | + end | |
88 | + | |
89 | + def test_rollback_with_version_class_with_options | |
90 | + p = locked_pages(:welcome) | |
91 | + assert_equal 'Welcome to the weblog', p.title | |
92 | + assert_equal 'LockedPage', p.versions.first.version_type | |
93 | + | |
94 | + assert p.revert_to!(p.versions.first), "Couldn't revert to 1" | |
95 | + assert_equal 'Welcome to the weblg', p.title | |
96 | + assert_equal 'LockedPage', p.versions.first.version_type | |
97 | + end | |
98 | + | |
99 | + def test_saves_versioned_copy_with_sti | |
100 | + p = SpecialLockedPage.create! :title => 'first title' | |
101 | + assert !p.new_record? | |
102 | + assert_equal 1, p.versions.size | |
103 | + assert_instance_of LockedPage.versioned_class, p.versions.first | |
104 | + assert_equal 'SpecialLockedPage', p.versions.first.version_type | |
105 | + end | |
106 | + | |
107 | + def test_rollback_with_version_number_with_sti | |
108 | + p = locked_pages(:thinking) | |
109 | + assert_equal 'So I was thinking', p.title | |
110 | + | |
111 | + assert p.revert_to!(p.versions.first.version), "Couldn't revert to 1" | |
112 | + assert_equal 'So I was thinking!!!', p.title | |
113 | + assert_equal 'SpecialLockedPage', p.versions.first.version_type | |
114 | + end | |
115 | + | |
116 | + def test_lock_version_works_with_versioning | |
117 | + p = locked_pages(:thinking) | |
118 | + p2 = LockedPage.find(p.id) | |
119 | + | |
120 | + p.title = 'fresh title' | |
121 | + p.save | |
122 | + assert_equal 2, p.versions.size # limit! | |
123 | + | |
124 | + assert_raises(ActiveRecord::StaleObjectError) do | |
125 | + p2.title = 'stale title' | |
126 | + p2.save | |
127 | + end | |
128 | + end | |
129 | + | |
130 | + def test_version_if_condition | |
131 | + p = Page.create! :title => "title" | |
132 | + assert_equal 1, p.version | |
133 | + | |
134 | + Page.feeling_good = false | |
135 | + p.save | |
136 | + assert_equal 1, p.version | |
137 | + Page.feeling_good = true | |
138 | + end | |
139 | + | |
140 | + def test_version_if_condition2 | |
141 | + # set new if condition | |
142 | + Page.class_eval do | |
143 | + def new_feeling_good() title[0..0] == 'a'; end | |
144 | + alias_method :old_feeling_good, :feeling_good? | |
145 | + alias_method :feeling_good?, :new_feeling_good | |
146 | + end | |
147 | + | |
148 | + p = Page.create! :title => "title" | |
149 | + assert_equal 1, p.version # version does not increment | |
150 | + assert_equal 1, p.versions(true).size | |
151 | + | |
152 | + p.update_attributes(:title => 'new title') | |
153 | + assert_equal 1, p.version # version does not increment | |
154 | + assert_equal 1, p.versions(true).size | |
155 | + | |
156 | + p.update_attributes(:title => 'a title') | |
157 | + assert_equal 2, p.version | |
158 | + assert_equal 2, p.versions(true).size | |
159 | + | |
160 | + # reset original if condition | |
161 | + Page.class_eval { alias_method :feeling_good?, :old_feeling_good } | |
162 | + end | |
163 | + | |
164 | + def test_version_if_condition_with_block | |
165 | + # set new if condition | |
166 | + old_condition = Page.version_condition | |
167 | + Page.version_condition = Proc.new { |page| page.title[0..0] == 'b' } | |
168 | + | |
169 | + p = Page.create! :title => "title" | |
170 | + assert_equal 1, p.version # version does not increment | |
171 | + assert_equal 1, p.versions(true).size | |
172 | + | |
173 | + p.update_attributes(:title => 'a title') | |
174 | + assert_equal 1, p.version # version does not increment | |
175 | + assert_equal 1, p.versions(true).size | |
176 | + | |
177 | + p.update_attributes(:title => 'b title') | |
178 | + assert_equal 2, p.version | |
179 | + assert_equal 2, p.versions(true).size | |
180 | + | |
181 | + # reset original if condition | |
182 | + Page.version_condition = old_condition | |
183 | + end | |
184 | + | |
185 | + def test_version_no_limit | |
186 | + p = Page.create! :title => "title", :body => 'first body' | |
187 | + p.save | |
188 | + p.save | |
189 | + 5.times do |i| | |
190 | + assert_page_title p, i | |
191 | + end | |
192 | + end | |
193 | + | |
194 | + def test_version_max_limit | |
195 | + p = LockedPage.create! :title => "title" | |
196 | + p.update_attributes(:title => "title1") | |
197 | + p.update_attributes(:title => "title2") | |
198 | + 5.times do |i| | |
199 | + assert_page_title p, i, :lock_version | |
200 | + assert p.versions(true).size <= 2, "locked version can only store 2 versions" | |
201 | + end | |
202 | + end | |
203 | + | |
204 | + def test_track_changed_attributes_default_value | |
205 | + assert !Page.track_changed_attributes | |
206 | + assert LockedPage.track_changed_attributes | |
207 | + assert SpecialLockedPage.track_changed_attributes | |
208 | + end | |
209 | + | |
210 | + def test_version_order | |
211 | + assert_equal 23, pages(:welcome).versions.first.version | |
212 | + assert_equal 24, pages(:welcome).versions.last.version | |
213 | + end | |
214 | + | |
215 | + def test_track_changed_attributes | |
216 | + p = LockedPage.create! :title => "title" | |
217 | + assert_equal 1, p.lock_version | |
218 | + assert_equal 1, p.versions(true).size | |
219 | + | |
220 | + p.title = 'title' | |
221 | + assert !p.save_version? | |
222 | + p.save | |
223 | + assert_equal 2, p.lock_version # still increments version because of optimistic locking | |
224 | + assert_equal 1, p.versions(true).size | |
225 | + | |
226 | + p.title = 'updated title' | |
227 | + assert p.save_version? | |
228 | + p.save | |
229 | + assert_equal 3, p.lock_version | |
230 | + assert_equal 1, p.versions(true).size # version 1 deleted | |
231 | + | |
232 | + p.title = 'updated title!' | |
233 | + assert p.save_version? | |
234 | + p.save | |
235 | + assert_equal 4, p.lock_version | |
236 | + assert_equal 2, p.versions(true).size # version 1 deleted | |
237 | + end | |
238 | + | |
239 | + def assert_page_title(p, i, version_field = :version) | |
240 | + p.title = "title#{i}" | |
241 | + p.save | |
242 | + assert_equal "title#{i}", p.title | |
243 | + assert_equal (i+4), p.send(version_field) | |
244 | + end | |
245 | + | |
246 | + def test_find_versions | |
247 | + assert_equal 2, locked_pages(:welcome).versions.size | |
248 | + assert_equal 1, locked_pages(:welcome).versions.find(:all, :conditions => ['title LIKE ?', '%weblog%']).length | |
249 | + assert_equal 2, locked_pages(:welcome).versions.find(:all, :conditions => ['title LIKE ?', '%web%']).length | |
250 | + assert_equal 0, locked_pages(:thinking).versions.find(:all, :conditions => ['title LIKE ?', '%web%']).length | |
251 | + assert_equal 2, locked_pages(:welcome).versions.length | |
252 | + end | |
253 | + | |
254 | + def test_with_sequence | |
255 | + assert_equal 'widgets_seq', Widget.versioned_class.sequence_name | |
256 | + 3.times { Widget.create! :name => 'new widget' } | |
257 | + assert_equal 3, Widget.count | |
258 | + assert_equal 3, Widget.versioned_class.count | |
259 | + end | |
260 | + | |
261 | + def test_has_many_through | |
262 | + assert_equal [authors(:caged), authors(:mly)], pages(:welcome).authors | |
263 | + end | |
264 | + | |
265 | + def test_has_many_through_with_custom_association | |
266 | + assert_equal [authors(:caged), authors(:mly)], pages(:welcome).revisors | |
267 | + end | |
268 | + | |
269 | + def test_referential_integrity | |
270 | + pages(:welcome).destroy | |
271 | + assert_equal 0, Page.count | |
272 | + assert_equal 0, Page::Version.count | |
273 | + end | |
274 | + | |
275 | + def test_association_options | |
276 | + association = Page.reflect_on_association(:versions) | |
277 | + options = association.options | |
278 | + assert_equal :delete_all, options[:dependent] | |
279 | + assert_equal 'version', options[:order] | |
280 | + | |
281 | + association = Widget.reflect_on_association(:versions) | |
282 | + options = association.options | |
283 | + assert_equal :nullify, options[:dependent] | |
284 | + assert_equal 'version desc', options[:order] | |
285 | + assert_equal 'widget_id', options[:foreign_key] | |
286 | + | |
287 | + widget = Widget.create! :name => 'new widget' | |
288 | + assert_equal 1, Widget.count | |
289 | + assert_equal 1, Widget.versioned_class.count | |
290 | + widget.destroy | |
291 | + assert_equal 0, Widget.count | |
292 | + assert_equal 1, Widget.versioned_class.count | |
293 | + end | |
294 | + | |
295 | + def test_versioned_records_should_belong_to_parent | |
296 | + page = pages(:welcome) | |
297 | + page_version = page.versions.last | |
298 | + assert_equal page, page_version.page | |
299 | + end | |
300 | + | |
301 | + def test_unchanged_attributes | |
302 | + landmarks(:washington).attributes = landmarks(:washington).attributes.except("id") | |
303 | + assert !landmarks(:washington).changed? | |
304 | + end | |
305 | + | |
306 | + def test_unchanged_string_attributes | |
307 | + landmarks(:washington).attributes = landmarks(:washington).attributes.except("id").inject({}) { |params, (key, value)| params.update(key => value.to_s) } | |
308 | + assert !landmarks(:washington).changed? | |
309 | + end | |
310 | + | |
311 | + def test_should_find_earliest_version | |
312 | + assert_equal page_versions(:welcome_1), pages(:welcome).versions.earliest | |
313 | + end | |
314 | + | |
315 | + def test_should_find_latest_version | |
316 | + assert_equal page_versions(:welcome_2), pages(:welcome).versions.latest | |
317 | + end | |
318 | + | |
319 | + def test_should_find_previous_version | |
320 | + assert_equal page_versions(:welcome_1), page_versions(:welcome_2).previous | |
321 | + assert_equal page_versions(:welcome_1), pages(:welcome).versions.before(page_versions(:welcome_2)) | |
322 | + end | |
323 | + | |
324 | + def test_should_find_next_version | |
325 | + assert_equal page_versions(:welcome_2), page_versions(:welcome_1).next | |
326 | + assert_equal page_versions(:welcome_2), pages(:welcome).versions.after(page_versions(:welcome_1)) | |
327 | + end | |
328 | +end | |
0 | 329 | \ No newline at end of file | ... | ... |
... | ... | @@ -0,0 +1,19 @@ |
1 | +* April 2, 2007 * | |
2 | + | |
3 | +* don't copy the #full_filename to the default #temp_paths array if it doesn't exist | |
4 | +* add default ID partitioning for attachments | |
5 | +* add #binmode call to Tempfile (note: ruby should be doing this!) [Eric Beland] | |
6 | +* Check for current type of :thumbnails option. | |
7 | +* allow customization of the S3 configuration file path with the :s3_config_path option. | |
8 | +* Don't try to remove thumbnails if there aren't any. Closes #3 [ben stiglitz] | |
9 | + | |
10 | +* BC * (before changelog) | |
11 | + | |
12 | +* add default #temp_paths entry [mattly] | |
13 | +* add MiniMagick support to attachment_fu [Isacc] | |
14 | +* update #destroy_file to clear out any empty directories too [carlivar] | |
15 | +* fix references to S3Backend module [Hunter Hillegas] | |
16 | +* make #current_data public with db_file and s3 backends [ebryn] | |
17 | +* oops, actually svn add the files for s3 backend. [Jeffrey Hardy] | |
18 | +* experimental s3 support, egad, no tests.... [Jeffrey Hardy] | |
19 | +* doh, fix a few bad references to ActsAsAttachment [sixty4bit] | |
0 | 20 | \ No newline at end of file | ... | ... |