├── init.rb ├── Rakefile ├── MIT-LICENSE ├── test ├── test_helper.rb └── has_and_belongs_to_self_test.rb ├── README └── lib └── has_and_belongs_to_self.rb /init.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'has_and_belongs_to_self' 3 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 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 has_and_belongs_to_self plugin.' 9 | Rake::TestTask.new(:test) do |t| 10 | t.libs << 'lib' 11 | t.libs << 'test' 12 | t.pattern = 'test/**/*_test.rb' 13 | t.verbose = true 14 | end 15 | 16 | desc 'Generate documentation for the has_and_belongs_to_self plugin.' 17 | Rake::RDocTask.new(:rdoc) do |rdoc| 18 | rdoc.rdoc_dir = 'rdoc' 19 | rdoc.title = 'HasAndBelongsToSelf' 20 | rdoc.options << '--line-numbers' << '--inline-source' 21 | rdoc.rdoc_files.include('README') 22 | rdoc.rdoc_files.include('lib/**/*.rb') 23 | end 24 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 [name of plugin creator] 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 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'active_support' 3 | require 'active_support/test_case' 4 | require 'test/unit' 5 | require 'shoulda' 6 | require 'ruby-debug' 7 | require 'rr' 8 | require File.dirname(__FILE__) + '/../init.rb' 9 | 10 | ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :database => ":memory:") 11 | old = $stdout 12 | $stdout = StringIO.new 13 | 14 | ActiveRecord::Base.logger 15 | ActiveRecord::Schema.define(:version => 1) do 16 | create_table :test_table do |t| 17 | t.column :title, :string 18 | end 19 | 20 | create_table :test_table_relations, :id => false do |t| 21 | t.column :x_id, :integer 22 | t.column :y_id, :integer 23 | end 24 | end 25 | 26 | 27 | $stdout = old 28 | 29 | class TestTable < ActiveRecord::Base 30 | set_table_name 'test_table' 31 | has_and_belongs_to_self 32 | end 33 | 34 | #class TestTableRelation < ActiveRecord::Base; end 35 | 36 | 37 | class Test::Unit::TestCase 38 | include RR::Adapters::TestUnit 39 | 40 | def assert_table_name_equal(table, expected, &block) 41 | options_with_correct_table_name = satisfy {|arg| 42 | expected.to_s == arg[:join_table].to_s 43 | } 44 | mock(table).has_and_belongs_to_many(anything, options_with_correct_table_name).twice 45 | stub.proxy(ActiveRecord::Reflection::AssociationReflection).new(anything, anything, options_with_correct_table_name, anything) 46 | mock(table).add_association_callbacks(anything, options_with_correct_table_name) 47 | block.call 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | HasAndBelongsToSelf 2 | =================== 3 | 4 | I needed functionality of so called "friends", when record of table X has many records of the same table (X). 5 | Needed functionality is present in this plugin. 6 | 7 | To use it create migration 8 | 9 | class CreateXRelations 10 | def self.up 11 | create_table :x_relations, :id => false do |t| 12 | t.integer :x_id 13 | t.integer :y_id 14 | end 15 | end 16 | 17 | def self.down 18 | drop_table :x_relations 19 | end 20 | end 21 | 22 | and put one string to your class to make it look like 23 | 24 | class X 25 | has_and_belongs_to_self 26 | end 27 | 28 | 29 | X.first.relations - all relations records (records belong to each other in both ways, retrieved in one sql query) 30 | X.first.relations_x - so-called x-relations (one record belongs to other in one way) 31 | X.first.relations_y - so-called y-relations (one record belongs to other in other way) 32 | 33 | Migration 34 | ========= 35 | currently you can set other table name but columns x_id and y_id are required 36 | 37 | Syntax 38 | ====== 39 | 40 | There is an alias for has_and_belongs_to_self 41 | 42 | Following examples running ok 43 | 44 | class X 45 | has_and_belongs_to_its :friends 46 | # need to create table "x_friends" 47 | # X.first.friends - all relations records 48 | # X.first.relations_x - x-relations 49 | # X.first.relations_y - y-relations 50 | end 51 | 52 | class X 53 | has_and_belongs_to_its :friends, :join_table => "xxx" 54 | # need to create table "xxx" 55 | # X.first.friends - all relations records 56 | # X.first.relations_x - x-relations 57 | # X.first.relations_y - y-relations 58 | end 59 | 60 | class X 61 | has_and_belongs_to_self :join_table => "test" 62 | # need to create table "test" 63 | # X.first.relations - all relations records 64 | # X.first.relations_x - x-relations 65 | # X.first.relations_y - y-relations 66 | end 67 | 68 | class X 69 | has_and_belongs_to_self :test1, :join_table => "test2" 70 | # need to create table "test2" 71 | # X.first.test1 - all relations records 72 | # X.first.relations_x - x-relations 73 | # X.first.relations_y - y-relations 74 | end 75 | 76 | Following example is wrong 77 | 78 | class X 79 | has_and_belongs_to_its :join_table => "test" 80 | end 81 | 82 | Copyright (c) 2010 Olexiy Zamkoviy, released under the MIT license 83 | -------------------------------------------------------------------------------- /lib/has_and_belongs_to_self.rb: -------------------------------------------------------------------------------- 1 | # @author Olexiy Zamkoviy 2 | 3 | require 'active_record' 4 | 5 | ActiveRecord::Base.class_eval do 6 | 7 | def self.has_and_belongs_to_self(association_id_or_options = nil, options = {}) 8 | if association_id_or_options.is_a?(Hash) 9 | options = association_id_or_options 10 | association_id = nil 11 | else 12 | association_id = association_id_or_options 13 | end 14 | 15 | association_id = association_id || 'relations' 16 | has_and_belongs_to_its(association_id, options) 17 | end 18 | 19 | def self.has_and_belongs_to_its(association_id , options = {}) 20 | raise ArgumentError, "Association id not specified" unless association_id 21 | join_table = options[:join_table] || "#{self.table_name}_#{association_id}" 22 | 23 | has_and_belongs_to_many :relations_x, 24 | :class_name => to_s, 25 | :foreign_key => :x_id, 26 | :association_foreign_key => :y_id, 27 | :join_table => join_table 28 | has_and_belongs_to_many :relations_y, 29 | :class_name => to_s, 30 | :foreign_key => :y_id, 31 | :association_foreign_key => :x_id, 32 | :join_table => join_table 33 | 34 | options = { 35 | :class_name => to_s, 36 | :foreign_key => :x_id, 37 | :association_foreign_key => :y_id, 38 | :join_table => join_table 39 | } 40 | 41 | reflection = ActiveRecord::Reflection::AssociationReflection.new(:has_and_belongs_to_self, association_id, options, self) 42 | write_inheritable_hash :reflections, association_id => reflection 43 | collection_accessor_methods(reflection, HasAndBelongsToSelfAssociation) 44 | add_association_callbacks(reflection.name, options) 45 | end 46 | 47 | class HasAndBelongsToSelfAssociation < ActiveRecord::Associations::HasAndBelongsToManyAssociation 48 | def construct_sql 49 | if @reflection.options[:finder_sql] 50 | @finder_sql = interpolate_sql(@reflection.options[:finder_sql]) 51 | else 52 | @finder_sql = "#{@owner.connection.quote_table_name @reflection.options[:join_table]}.#{@reflection.primary_key_name} = #{owner_quoted_id} OR #{@owner.connection.quote_table_name @reflection.options[:join_table]}.#{@reflection.association_foreign_key} = #{owner_quoted_id}" 53 | @finder_sql << " AND (#{conditions})" if conditions 54 | end 55 | 56 | @join_sql = "INNER JOIN #{@owner.connection.quote_table_name @reflection.options[:join_table]} ON (#{@reflection.quoted_table_name}.#{@reflection.klass.primary_key} = #{@owner.connection.quote_table_name @reflection.options[:join_table]}.#{@reflection.association_foreign_key} OR #{@reflection.quoted_table_name}.#{@reflection.klass.primary_key} = #{@owner.connection.quote_table_name @reflection.options[:join_table]}.#{@reflection.options[:foreign_key]}) AND #{@reflection.quoted_table_name}.#{@reflection.klass.primary_key} != #{owner_quoted_id}" 57 | 58 | if @reflection.options[:counter_sql] 59 | @counter_sql = interpolate_sql(@reflection.options[:counter_sql]) 60 | elsif @reflection.options[:finder_sql] 61 | # replace the SELECT clause with COUNT(*), preserving any hints within /* ... */ 62 | @reflection.options[:counter_sql] = @reflection.options[:finder_sql].sub(/SELECT (\/\*.*?\*\/ )?(.*)\bFROM\b/im) { "SELECT #{$1}COUNT(*) FROM" } 63 | @counter_sql = interpolate_sql(@reflection.options[:counter_sql]) 64 | else 65 | @counter_sql = @finder_sql 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/has_and_belongs_to_self_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class HasAndBelongsToSelfTest < ActiveSupport::TestCase 4 | # Replace this with your real tests. 5 | context "ActiveRecord::Base" do 6 | should "have has_and_belongs_to_self method" do 7 | assert_respond_to ActiveRecord::Base, :has_and_belongs_to_self 8 | end 9 | 10 | should "have has_and_belongs_to_its" do 11 | assert_respond_to ActiveRecord::Base, :has_and_belongs_to_its 12 | end 13 | 14 | context "::has_and_belongs_to_self" do 15 | class TestTableX < ActiveRecord::Base; end 16 | 17 | should "accept hash_attributes" do 18 | assert_nothing_raised do 19 | TestTableX.has_and_belongs_to_self :join_table => "test" 20 | end 21 | end 22 | 23 | should "accept association_id" do 24 | assert_nothing_raised do 25 | TestTableX.has_and_belongs_to_self :test 26 | end 27 | end 28 | 29 | should "accept association_id and hash_attributes" do 30 | assert_nothing_raised do 31 | TestTableX.has_and_belongs_to_self :test, :join_table => "test" 32 | end 33 | end 34 | end 35 | 36 | context "::has_and_belongs_to_its" do 37 | class TestTableY < ActiveRecord::Base; end 38 | 39 | should "error when association_id not specified" do 40 | assert_raise ArgumentError do 41 | TestTableY.has_and_belongs_to_its 42 | end 43 | end 44 | 45 | should "error when specified hash instead of association_id" do 46 | assert_raise TypeError do 47 | TestTableY.has_and_belongs_to_its :join_table => "test" 48 | end 49 | end 50 | 51 | should "not error when association_id specified" do 52 | assert_nothing_raised do 53 | TestTableY.has_and_belongs_to_its :friends 54 | end 55 | end 56 | end 57 | end 58 | 59 | context "Self-linking table" do 60 | 61 | context "relation table name" do 62 | should "consist of '_relations' by default" do 63 | class TestTable2 < ActiveRecord::Base 64 | set_table_name 'test_table2' 65 | end 66 | 67 | assert_table_name_equal TestTable2, "test_table2_relations" do 68 | TestTable2.has_and_belongs_to_self 69 | end 70 | end 71 | 72 | should "consist of '_'" do 73 | class TestTable3 < ActiveRecord::Base 74 | set_table_name 'test_table3' 75 | end 76 | 77 | assert_table_name_equal TestTable3, "test_table3_friends" do 78 | TestTable3.has_and_belongs_to_self :friends 79 | end 80 | end 81 | 82 | should "be as set by :join_table parameter" do 83 | class TestTable4 < ActiveRecord::Base 84 | set_table_name 'test_table4' 85 | end 86 | 87 | assert_table_name_equal TestTable4, "test" do 88 | TestTable4.has_and_belongs_to_self :join_table => "test" 89 | end 90 | end 91 | end 92 | 93 | context "instance" do 94 | setup do 95 | @instance = TestTable.create! 96 | @instance2 = TestTable.create! 97 | @instance3 = TestTable.create! 98 | end 99 | 100 | should "have relations methods" do 101 | assert_respond_to @instance, :relations_x 102 | assert_respond_to @instance, :relations_y 103 | assert_respond_to @instance, :relations 104 | end 105 | 106 | should "have 1 relation" do 107 | @instance2 = TestTable.create!(:title => 'test') 108 | assert @instance.relations_x.empty? 109 | @instance.relations_x.push(@instance2) 110 | assert_equal 1, @instance.relations_x.size 111 | assert_equal @instance.relations_x.first, @instance2 112 | assert_equal @instance2.relations_y.first, @instance 113 | end 114 | 115 | should "have equal relations count and size" do 116 | @instance.relations << @instance2 117 | 118 | assert_equal 1, @instance.relations.count 119 | assert_equal @instance.relations.count, @instance.relations.size 120 | 121 | @instance.relations << @instance3 122 | 123 | assert_equal 2, @instance.relations.count 124 | assert_equal @instance.relations.count, @instance.relations.size 125 | end 126 | 127 | should "have 2 different relations" do 128 | #x relation to instance1 129 | @instance.relations << @instance2 130 | #y relation to instance1 131 | @instance3.relations << @instance 132 | 133 | assert_equal [@instance], @instance2.relations_y 134 | assert_equal [@instance], @instance3.relations_x 135 | assert_equal [@instance2, @instance3], @instance.relations 136 | assert !@instance2.relations.include?(@instance3) 137 | assert !@instance3.relations.include?(@instance2) 138 | end 139 | end 140 | 141 | end 142 | end 143 | --------------------------------------------------------------------------------