acts_as_network 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ bin
2
+ .bundle
3
+ test/debug.log
4
+ test/acts_as_network.test.db
5
+ pkg
data/.rvmrc ADDED
@@ -0,0 +1 @@
1
+ rvm --create use 1.9.3@acts_as_network
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in acts_as_network.gemspec
4
+ gemspec
@@ -0,0 +1,97 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ acts_as_network (0.1.0)
5
+ rails (~> 3.2.0)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ actionmailer (3.2.1)
11
+ actionpack (= 3.2.1)
12
+ mail (~> 2.4.0)
13
+ actionpack (3.2.1)
14
+ activemodel (= 3.2.1)
15
+ activesupport (= 3.2.1)
16
+ builder (~> 3.0.0)
17
+ erubis (~> 2.7.0)
18
+ journey (~> 1.0.1)
19
+ rack (~> 1.4.0)
20
+ rack-cache (~> 1.1)
21
+ rack-test (~> 0.6.1)
22
+ sprockets (~> 2.1.2)
23
+ activemodel (3.2.1)
24
+ activesupport (= 3.2.1)
25
+ builder (~> 3.0.0)
26
+ activerecord (3.2.1)
27
+ activemodel (= 3.2.1)
28
+ activesupport (= 3.2.1)
29
+ arel (~> 3.0.0)
30
+ tzinfo (~> 0.3.29)
31
+ activeresource (3.2.1)
32
+ activemodel (= 3.2.1)
33
+ activesupport (= 3.2.1)
34
+ activesupport (3.2.1)
35
+ i18n (~> 0.6)
36
+ multi_json (~> 1.0)
37
+ arel (3.0.0)
38
+ builder (3.0.0)
39
+ erubis (2.7.0)
40
+ hike (1.2.1)
41
+ i18n (0.6.0)
42
+ journey (1.0.1)
43
+ json (1.6.5)
44
+ mail (2.4.1)
45
+ i18n (>= 0.4.0)
46
+ mime-types (~> 1.16)
47
+ treetop (~> 1.4.8)
48
+ mime-types (1.17.2)
49
+ minitest (2.11.1)
50
+ multi_json (1.0.4)
51
+ polyglot (0.3.3)
52
+ rack (1.4.1)
53
+ rack-cache (1.1)
54
+ rack (>= 0.4)
55
+ rack-ssl (1.3.2)
56
+ rack
57
+ rack-test (0.6.1)
58
+ rack (>= 1.0)
59
+ rails (3.2.1)
60
+ actionmailer (= 3.2.1)
61
+ actionpack (= 3.2.1)
62
+ activerecord (= 3.2.1)
63
+ activeresource (= 3.2.1)
64
+ activesupport (= 3.2.1)
65
+ bundler (~> 1.0)
66
+ railties (= 3.2.1)
67
+ railties (3.2.1)
68
+ actionpack (= 3.2.1)
69
+ activesupport (= 3.2.1)
70
+ rack-ssl (~> 1.3.2)
71
+ rake (>= 0.8.7)
72
+ rdoc (~> 3.4)
73
+ thor (~> 0.14.6)
74
+ rake (0.9.2.2)
75
+ rdoc (3.12)
76
+ json (~> 1.4)
77
+ sprockets (2.1.2)
78
+ hike (~> 1.2)
79
+ rack (~> 1.0)
80
+ tilt (~> 1.1, != 1.3.0)
81
+ sqlite3 (1.3.5)
82
+ sqlite3-ruby (1.3.3)
83
+ sqlite3 (>= 1.3.3)
84
+ thor (0.14.6)
85
+ tilt (1.3.3)
86
+ treetop (1.4.10)
87
+ polyglot
88
+ polyglot (>= 0.3.1)
89
+ tzinfo (0.3.31)
90
+
91
+ PLATFORMS
92
+ ruby
93
+
94
+ DEPENDENCIES
95
+ acts_as_network!
96
+ minitest
97
+ sqlite3-ruby
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License
2
+
3
+ Copyright (c) 2007 Zetetic LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
22
+
@@ -0,0 +1,275 @@
1
+ # acts_as_network
2
+
3
+ This gem is intended to simplify the definition
4
+ and storage of reciprocal relationships between entities using
5
+ `ActiveRecord`, exposing a "network" of two-way connections between
6
+ records. It does this in DRY way using only **a single record**
7
+ in a `has_and_belongs_to_many` join table or `has_many :through`
8
+ join model. Thus, there is no redundancy and you need only one instance of
9
+ an association or join model to represent both directions of the relationship.
10
+
11
+ This is especially useful for social networks where
12
+ a *friend* relationship in one direction implies the reverse
13
+ relationship (when Jack is a friend of Jane, Jane should also
14
+ be a friend of Jack).
15
+
16
+ ## History
17
+
18
+ [Zetetic LLC](http://www.zetetic.net) extracted `acts_as_network` from
19
+ [PingMe](http://www.gopingme.com) where it drives the social
20
+ networking features of the site.
21
+
22
+ [ExamTime](http://www.examtime.com) forked the project in February 2012
23
+ to repackage it from a Rails 2 plugin to a Rails 3 gem. Minimal code
24
+ changes have been made. Significant changes were pulled in from
25
+ [Erik Hollensbe's
26
+ fork](https://github.com/erikh/acts_as_network/commits/rails3/lib/zetetic/acts)
27
+
28
+ ## Installation
29
+
30
+ Add this line to your application's `Gemfile`:
31
+
32
+ gem 'acts_as_network'
33
+
34
+ And then execute:
35
+
36
+ $ bundle
37
+
38
+ Or install it yourself as:
39
+
40
+ $ gem install acts_as_network
41
+
42
+ ## Contributing
43
+
44
+ This fork is maintained on GitHub:
45
+ git@github.com:ExamTime/acts_as_network.git
46
+
47
+ The original project is here:
48
+ http://github.com/sjlombardo/acts_as_network/tree/master
49
+
50
+ 1. Fork it
51
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
52
+ 3. Commit your changes (`git commit -am 'Added some feature'`)
53
+ 4. Push to the branch (`git push origin my-new-feature`)
54
+ 5. Create new Pull Request
55
+
56
+ ## Usage
57
+
58
+ The usual way of representing network relationships in a database is
59
+ to use an intermediate, often self-referential, join table (HABTM).
60
+ For example one might define a simple `Person` type
61
+
62
+ ```ruby
63
+ create_table :people, :force => true do |t|
64
+ t.column :name, :string
65
+ end
66
+ ```
67
+
68
+ and then a join table to store the friendship relation
69
+
70
+ ```ruby
71
+ create_table :friends, {:id => false} do |t|
72
+ t.column :person_id, :integer, :null => false
73
+ t.column :person_id_friend, :integer, :null => false # target of the relationship
74
+ end
75
+ ```
76
+
77
+ Unfortunately this model requires *two* rows in the intermediate table to
78
+ make a relationship bi-directional
79
+
80
+ ```ruby
81
+ jane = Person.create(:name => 'Jane')
82
+ jack = Person.create(:name => 'Jack')
83
+
84
+ jane.friends << jack # Jack is Jane's friend
85
+ jane.friends.include?(jack) # => true
86
+ ```
87
+
88
+ Clearly Jack is Jane's friend, yet Jane is *not* Jack's friend
89
+
90
+ ```ruby
91
+ jack.friends.include?(jane) # => false
92
+ ```
93
+
94
+ unless you need to explicitly define the reverse relation
95
+
96
+ ```ruby
97
+ jack.friends << jane
98
+ ```
99
+
100
+ Of course, this isn't horrible, and can in fact be implemented
101
+ in a fairly DRY way using association callbacks. However, things get
102
+ more complicated when you consider disassociation (what to do when Jane
103
+ doesn't want to be friends with Jack any more), or the very common
104
+ case where you want to express the relationship through a more complicated
105
+ join model via `has_many :through`
106
+
107
+ ```ruby
108
+ create_table :invites do |t|
109
+ t.column :person_id, :integer, :null => false # source of the relationship
110
+ t.column :person_id_friend, :integer, :null => false # target of the relationship
111
+ t.column :code, :string # random invitation code
112
+ t.column :message, :text # invitation message
113
+ t.column :is_accepted, :boolean
114
+ t.column :accepted_at, :timestamp # when did they accept?
115
+ end
116
+ ```
117
+
118
+ In this case creating a reverse relationship is painful, and depending on
119
+ validations might require the duplication of multiple values, making the
120
+ data model decidedly un-DRY.
121
+
122
+ ### Using acts_as_network
123
+
124
+ Acts As Network DRYs things up by representing only a single record
125
+ in a `has_and_belongs_to_many` join table or `has_many :through`
126
+ join model. Thus, you only need one instance of an association or join model to
127
+ represent both directions of the relationship.
128
+
129
+ ### With HABTM
130
+
131
+ For a HABTM style relationship, it's as simple as
132
+
133
+ ```ruby
134
+ class Person < ActiveRecord::Base
135
+ acts_as_network :friends, :join_table => :friends
136
+ end
137
+ ```
138
+
139
+ In this case `acts_as_network` will expose three new properies
140
+ on the Person model
141
+
142
+ ```ruby
143
+ me.friends_out # friends where I have originated the friendship
144
+ # (people I consider friends)
145
+
146
+ me.friends_in # friends where they originated the friendship
147
+ # (people who consider me a friend)
148
+
149
+ me.friends # the union of the two sets, that is all people who I consider
150
+ # friends and all those who consider me a friend
151
+ ```
152
+
153
+ Thus
154
+
155
+ ```ruby
156
+ jane = Person.create(:name => 'Jane')
157
+ jack = Person.create(:name => 'Jack')
158
+
159
+ jane.friends_out << jack # Jane adds Jack as a friend
160
+ jane.friends.include?(jack) => true # Jack is Janes friend
161
+ jack.friends.include?(jane) => true # Jane is also Jack's friend!
162
+ ```
163
+
164
+ ### With a join model
165
+
166
+ This may seem more natural when considering a join style with a proper Invite model. In this case
167
+ one person will "invite" another person to be friends.
168
+
169
+ ```ruby
170
+ class Invite < ActiveRecord::Base
171
+ belongs_to :person
172
+ belongs_to :person_target, :class_name => 'Person', :foreign_key => 'person_id_target' # the target of the friend relationship
173
+ validates_presence_of :person, :person_target
174
+ end
175
+
176
+ class Person < ActiveRecord::Base
177
+ acts_as_network :friends, :through => :invites, [:conditions => "is_accepted = ?", true]
178
+ end
179
+ ```
180
+
181
+ In this case `acts_as_network` implicitly defines five new properties on
182
+ the `Person` model:
183
+
184
+ ```ruby
185
+ person.invites_out # has_many invites originating from me to others
186
+ person.invites_in # has_many invites orginiating from others to me
187
+ person.friends_out # has_many friends :through outbound accepted invites from me to others
188
+ person.friends_in # has_many friends :through inbound accepted invites from others to me
189
+ person.friends # the union of the two friend sets - all people who I have
190
+ # invited and all the people who have invited me
191
+ ```
192
+
193
+ Thus
194
+
195
+ ```ruby
196
+ jane = Person.create(:name => 'Jane')
197
+ jack = Person.create(:name => 'Jack')
198
+
199
+ # Jane invites Jack to be friends
200
+ invite = Invite.create(:person => jane, :person_target => jack, :message => "let's be friends!")
201
+
202
+ jane.friends.include?(jack) => false # Jack is not yet Jane's friend
203
+ jack.friends.include?(jane) => false # Jane is not yet Jack's friend either
204
+
205
+ invite.is_accepted = true # Now Jack accepts the invite
206
+ invite.save and jane.reload and jack.reload
207
+
208
+ jane.friends.include?(jack) => true # Jack is Jane's friend now
209
+ jack.friends.include?(jane) => true # Jane is also Jack's friend
210
+ ```
211
+
212
+ For more details and specific options see `ActsAsNetwork::Network::ClassMethods`
213
+
214
+ The applications of this plugin to social network situations are fairly obvious,
215
+ but it should also be usable in the general case to represent inherant
216
+ bi-directional relationships between entities.
217
+
218
+ #### Migrations
219
+
220
+ This Gem does not attempt to help you write your migrations. For the
221
+ join example above, the changes to the model and the corresponding
222
+ migrations would be:
223
+
224
+ ```ruby
225
+ class Person < ActiveRecord::Base
226
+ ...
227
+ acts_as_network :friends,
228
+ :through => :invites,
229
+ :conditions => [ "is_accepted = ?", true ],
230
+ :association_foreign_key => "person_target_id"
231
+ ```
232
+
233
+ ```ruby
234
+ class CreateInvite < ActiveRecord::Migration
235
+ def change
236
+ create_table :invites do |t|
237
+ t.integer :person_id
238
+ t.integer :person_target_id
239
+ t.text :message
240
+ t.boolean :is_accepted
241
+ t.timestamps
242
+ end
243
+ end
244
+ end
245
+
246
+ class CreateFriends < ActiveRecord::Migration
247
+ def change
248
+ create_table :friends do |t|
249
+ t.integer :person_id
250
+ t.integer :person_id_friend
251
+ t.timestamps
252
+ end
253
+ end
254
+ end
255
+ ```
256
+
257
+ ## Tests
258
+
259
+ The plugin's unit tests are located in `test` directory under
260
+ `vendor/plugins/acts_as_network`. Run:
261
+
262
+ ```
263
+ [%] cd vendor/plugins/acts_as_network
264
+ [%] ruby test/network_test.rb
265
+ ```
266
+
267
+ This will create a temporary `sqlite3` database, a number of tables,
268
+ fixture data, and run the tests. You can delete the sqlite database
269
+ when you are done.
270
+
271
+ ```
272
+ [%] rm acts_as_network.test.db
273
+ ```
274
+
275
+ The test suite requires `sqlite3`.
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env rake
2
+
3
+ require "bundler/gem_tasks"
4
+ require 'rake/testtask'
5
+
6
+ Rake::TestTask.new(:test) do |test|
7
+ test.libs << 'lib' << 'test'
8
+ test.pattern = 'test/**/*_test.rb'
9
+ test.verbose = true
10
+ end
@@ -0,0 +1,22 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/acts_as_network/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["Zetetic LLC (Stephen Lombardo), David Kennedy"]
6
+ gem.email = ["david.kennedy@examtime.com"]
7
+ gem.description = %q{Simplify the definition and storage of "network" relationships, especially useful for social networks.}
8
+ gem.summary = %q{Simplify social network relationships}
9
+ gem.homepage = "https://github.com/ExamTime/acts_as_network"
10
+
11
+ gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
12
+ gem.files = `git ls-files`.split("\n")
13
+ gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
14
+ gem.name = "acts_as_network"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = ActsAsNetwork::VERSION
17
+
18
+ gem.add_development_dependency "minitest"
19
+ gem.add_development_dependency "sqlite3-ruby"
20
+ gem.add_dependency "rails", "~> 3.2.0"
21
+
22
+ end
@@ -0,0 +1,324 @@
1
+ require "acts_as_network/version"
2
+ require "active_record"
3
+
4
+ #
5
+ # ActsAsNetwork contains
6
+ # * ActsAsNetwork::Network::ClassMethods - provides the actual acts_as_network ActiveRecord functionality
7
+ # * ActsAsNetwork::UnionCollection - the basis for the "union" capability that allows acts_as_network
8
+ # to expose both inbound and outbound relationships in a single collection
9
+ #
10
+ module ActsAsNetwork
11
+ # = UnionCollection
12
+ # UnionCollection provides useful application-space functionality
13
+ # for emulating set unions acrosss ActiveRecord collections.
14
+ #
15
+ # A UnionCollection can be initialized with zero or more sets,
16
+ # although generally it must contain at least two to do anything
17
+ # useful. Once initialized, the UnionCollection itself will
18
+ # act as an array containing all of the records from each of its
19
+ # member sets. The following will create a union object containing
20
+ # the unique results of each individual find
21
+ #
22
+ # union = ActsAsNetwork::UnionCollection.new(
23
+ # Person.find(:all, :conditions => "id <= 1"), # set 0
24
+ # Person.find(:all, :conditions => "id >= 10 AND id <= 15"), # set 1
25
+ # Person.find(:all, :conditions => "id >= 20") # set 2
26
+ # )
27
+ #
28
+ # UnionCollection's more interesting feature is how it will
29
+ # intelligently forward ActiveRecord method calls to its member
30
+ # sets. This allows you to execute find operations directly on a
31
+ # UnionCollection, that will be executed on one or more
32
+ # of the member sets. Given the prior definition calling
33
+ #
34
+ # union.find(:all, :conditions => "id <= 1 OR id >= 20")
35
+ #
36
+ # would return an array containing all the records from set 0
37
+ # and set 2 (set 1 would be implicity excluded by the <tt>:conditions</tt>),
38
+ #
39
+ # union.find_by_name('george')
40
+ #
41
+ # would return a single entry fetched from set 2 if george's id was >= 20,
42
+ #
43
+ # union.find(30)
44
+ #
45
+ # would retrieve the record from set 2 with id == 30, and
46
+ #
47
+ # union.find(9)
48
+ #
49
+ # would throw an #ActiveRecord::RecordNotFound exception because that id
50
+ # is specifically excluded from the union's member sets.
51
+ #
52
+ # UnionCollection operates according to the following rules:
53
+ #
54
+ # * <tt>find :first</tt> - will search the sets in order and return the
55
+ # first record that matches the find criteria.
56
+ # * <tt>find :all</tt> - will search the sets, returning a
57
+ # UnionCollection containing the all matching results. This UnionCollection
58
+ # can, of course, be searched further
59
+ # * <tt>find(ids)</tt> - will look through all member sets in search
60
+ # of records with the given ids. #ActiveRecord::RecordNotFound will
61
+ # be raised unless all the IDs are located.
62
+ # * <tt>find_by_*</tt> - works as expected, behaving like <tt>find :first</tt>
63
+ # * <tt>find_all_by_*</tt> - works as expected like <tt>find :all</tt>
64
+ #
65
+ class UnionCollection
66
+
67
+ # UnionCollection should be initialized with a list of ActiveRecord collections
68
+ #
69
+ # union = ActsAsNetwork::UnionCollection.new(
70
+ # Person.find(:all, :conditions => "id <= 1"), # dynamic find set
71
+ # Person.managers # an model association
72
+ # )
73
+ #
74
+ def initialize(*sets)
75
+ @sets = sets || []
76
+ @sets.compact! # remove nil elements
77
+ end
78
+
79
+ # Emulates the ActiveRecord::base.find method.
80
+ # Accepts all the same arguments and options
81
+ #
82
+ # union.find(:first, :conditions => ["name = ?", "George"])
83
+ #
84
+ def find(*args)
85
+ case args.first
86
+ when :first then find_initial(:find, *args)
87
+ when :all then find_all(:find, *args)
88
+ else find_from_ids(:find, *args)
89
+ end
90
+ end
91
+
92
+ def to_a
93
+ load_sets
94
+ @arr
95
+ end
96
+
97
+ private
98
+
99
+ def load_sets
100
+ @arr = []
101
+ @sets.each{|set| @arr.concat set unless set.nil?} unless @sets.nil?
102
+ @arr.uniq!
103
+ end
104
+
105
+ # start by passing the find to set 0. If no results are returned
106
+ # pass the find on to set 1, and so on.
107
+ def find_initial(method_id, *args)
108
+ # conditions get ANDed together on subequent runs in this scope
109
+ # by ActiveRecord. We'lls separate the conditions out, save a copy
110
+ # of the initial state, and pass it to subsequent runs
111
+ conditions = args[1][:conditions] if args.size > 1 and args[1].kind_of?(Hash)
112
+
113
+ # this iteration is a great opportunity for future optimization -
114
+ # with find initial there is no need to continue processing once we
115
+ # find a match
116
+ results = @sets.collect { |set|
117
+ args[1][:conditions] = conditions unless conditions.nil?
118
+ set.empty? ? nil : set.send(method_id, *args)
119
+ }.compact
120
+ results.size > 0 ? results[0] : nil
121
+ end
122
+
123
+ def find_all(method_id, *args)
124
+ # create a new UnionCollection with new member sets containing the
125
+ # results of the find accross the current member sets
126
+ UnionCollection.new(*@sets.collect{|set| set.empty? ? nil : set.send(method_id, *Marshal::load(Marshal.dump(args))) })
127
+ end
128
+
129
+ # Invokes method against set1, catching ActiveRecord::RecordNotFound.
130
+ # if exception is raised try the method execution against set2
131
+ def find_from_ids(method_id, *args)
132
+ res = []
133
+
134
+ # another good target for future optimization - if only
135
+ # one id is presented for the search there is no need to proxy
136
+ # the call out to ever set - we can stop when we hit a match
137
+ args.each do |id|
138
+ @sets.each do |set|
139
+ begin
140
+ res << set.send(method_id, id) unless set.empty?
141
+ rescue ActiveRecord::RecordNotFound
142
+ # rethrow later
143
+ end
144
+ end
145
+ end
146
+
147
+ res.uniq!
148
+ if args.uniq.size != res.size
149
+ #FIXME
150
+ raise ActiveRecord::RecordNotFound.new "Couldn't find all records with IDs (#{args.join ','})"
151
+ end
152
+ args.size == 1 ? res[0] : res
153
+ end
154
+
155
+ # Handle find_by convenience methods
156
+ def method_missing(method_id, *args, &block)
157
+ if method_id.to_s =~ /^find_all_by/
158
+ find_all method_id, *args, &block
159
+ elsif method_id.to_s =~ /^find_by/
160
+ find_initial method_id, *args, &block
161
+ else
162
+ load_sets
163
+ @arr.send method_id, *args, &block
164
+ end
165
+ end
166
+ end
167
+
168
+ module Network #:nodoc:
169
+ def self.included(base)
170
+ base.extend ClassMethods
171
+ end
172
+
173
+ module ClassMethods
174
+ # = acts_as_network
175
+ #
176
+ # ActsAsNetwork expects a few things to be present before it is
177
+ # called. Namely, you need to establish the existance of either
178
+ # 1. a HABTM join table; or
179
+ # 2. an intermediate Join model
180
+ #
181
+ # == HABTM
182
+ #
183
+ # In the first case, +acts_as_network+ will assume that your HABTM table is named
184
+ # in a self-referential manner based on the model name. i.e. if your model is called
185
+ # +Person+ it will assume the HABTM join table is called +people_people+.
186
+ # It will also default the +foreign_key+ column to be named after the model: +person_id+.
187
+ # The default +association_foreign_key+ column will be the +foreign_key+ name with +_target+
188
+ # appended.
189
+ #
190
+ # acts_as_network :friends
191
+ #
192
+ # You can override any of these options in your call to +acts_as_network+. The
193
+ # following will use a join table named +friends+ with a foreign key of +person_id+
194
+ # and an association foreign key of +friend_id+
195
+ #
196
+ # acts_as_network :friends, :join_table => :friends, :foreign_key => 'person_id', :association_foreign_key => 'friend_id'
197
+ #
198
+ # == Join Model
199
+ #
200
+ # In the second case +acts_as_network+ will need to be told which model to use to perform the join - this is
201
+ # accomplished by passing a symbol for the join model to the <tt>:through</tt> option. So, with a join model called invites
202
+ # use:
203
+ #
204
+ # acts_as_network :friends, :through => :invites
205
+ #
206
+ # The same assumptions are made relative to the foreign_key and association_foreign_key columns, which can be overriden using
207
+ # the same options. It may be useful to include <tt>:conditions</tt> as well depending on the specific requirements of the
208
+ # join model. The following will create a network relation using a join model named +Invite+ with a foreign_key of
209
+ # +person_id+, an association_foreign_key of +friend_id+, where the Invite's +is_accepted+ field
210
+ # is true.
211
+ #
212
+ # acts_as_network :friends, :through => :invites, :foreign_key => 'person_id',
213
+ # :association_foreign_key => 'friend_id', [:conditions => "is_accepted = ?", true]
214
+ #
215
+ # The valid configuration options that can be passed to +acts_as_network+ follow:
216
+ #
217
+ # * <tt>:through</tt> - class to use for has_many :through relationship. If omitted acts_as_network
218
+ # will fall back on a HABTM relation
219
+ # * <tt>:join_table</tt> - when using a simple HABTM relation, this allows you to override the
220
+ # name of the join table. Defaults to <tt>model_model</tt> format, i.e. people_people
221
+ # * <tt>:foreign_key</tt> - name of the foreign key for the origin side of relation -
222
+ # i.e. person_id.
223
+ # * <tt>:association_foreign_key</tt> - name of the foreign key for the target side,
224
+ # i.e. person_id_target. Defaults to the same value as +foreign_key+ with a <tt>_target</tt> suffix
225
+ # * <tt>:conditions</tt> - optional, standard ActiveRecord SQL contition clause
226
+ #
227
+ def acts_as_network(relationship, options = {})
228
+ configuration = {
229
+ :foreign_key => name.foreign_key,
230
+ :association_foreign_key => "#{name.foreign_key}_target",
231
+ :join_table => "#{name.tableize}_#{name.tableize}"
232
+ }
233
+ configuration.update(options) if options.is_a?(Hash)
234
+
235
+ if configuration[:through].nil?
236
+ has_and_belongs_to_many "#{relationship}_out".to_sym, :class_name => name,
237
+ :foreign_key => configuration[:foreign_key], :association_foreign_key => configuration[:association_foreign_key],
238
+ :join_table => configuration[:join_table], :conditions => configuration[:conditions]
239
+
240
+ has_and_belongs_to_many "#{relationship}_in".to_sym, :class_name => name,
241
+ :foreign_key => configuration[:association_foreign_key], :association_foreign_key => configuration[:foreign_key],
242
+ :join_table => configuration[:join_table], :conditions => configuration[:conditions]
243
+
244
+ else
245
+
246
+ through_class = configuration[:through].to_s.classify
247
+ through_sym = configuration[:through]
248
+
249
+ # a node has many outbound relationships
250
+ has_many "#{through_sym}_out".to_sym, :class_name => through_class,
251
+ :foreign_key => configuration[:foreign_key]
252
+ has_many "#{relationship}_out".to_sym, :through => "#{through_sym}_out".to_sym,
253
+ :source => "#{name.tableize.singularize}_target", :foreign_key => configuration[:foreign_key],
254
+ :conditions => configuration[:conditions]
255
+
256
+ # a node has many inbound relationships
257
+ has_many "#{through_sym}_in".to_sym, :class_name => through_class,
258
+ :foreign_key => configuration[:association_foreign_key]
259
+ has_many "#{relationship}_in".to_sym, :through => "#{through_sym}_in".to_sym,
260
+ :source => name.tableize.singularize, :foreign_key => configuration[:association_foreign_key],
261
+ :conditions => configuration[:conditions]
262
+
263
+ # when using a join model, define a method providing a unioned view of all the join
264
+ # records. i.e. if People acts_as_network :contacts :through => :invites, this method
265
+ # is defined as def invites
266
+ class_eval <<-EOV
267
+ acts_as_union :#{through_sym}, [ :#{through_sym}_in, :#{through_sym}_out ]
268
+ EOV
269
+
270
+ end
271
+
272
+ # define the accessor method for the reciprocal network relationship view itself.
273
+ # i.e. if People acts_as_network :contacts, this method is defind as def contacts
274
+ class_eval <<-EOV
275
+ acts_as_union :#{relationship}, [ :#{relationship}_in, :#{relationship}_out ]
276
+ EOV
277
+ end
278
+ end
279
+ end
280
+
281
+ module Union
282
+ def self.included(base)
283
+ base.extend ClassMethods
284
+ end
285
+
286
+ module ClassMethods
287
+ # = acts_as_union
288
+ # acts_as_union simply presents a union'ed view of one or more ActiveRecord
289
+ # relationships (has_many or has_and_belongs_to_many, acts_as_network, etc).
290
+ #
291
+ # class Person < ActiveRecord::Base
292
+ # acts_as_network :friends
293
+ # acts_as_network :colleagues, :through => :invites, :foreign_key => 'person_id',
294
+ # :conditions => ["is_accepted = ?", true]
295
+ # acts_as_union :aquantainces, [:friends, :colleagues]
296
+ # end
297
+ #
298
+ # In this case a call to the +aquantainces+ method will return a UnionCollection on both
299
+ # a person's +friends+ and their +colleagues+. Likewise, finder operations will work accross
300
+ # the two distinct sets as if they were one. Thus, for the following code
301
+ #
302
+ # stephen = Person.find_by_name('Stephen')
303
+ # # search for user by login
304
+ # billy = stephen.aquantainces.find_by_name('Billy')
305
+ #
306
+ # both Stephen's +friends+ and +colleagues+ collections would be searched for someone named Billy.
307
+ #
308
+ # +acts_as_union+ doesn't accept any options.
309
+ #
310
+ def acts_as_union(relationship, methods)
311
+ # define the accessor method for the union.
312
+ # i.e. if People acts_as_union :jobs, this method is defined as def jobs
313
+ class_eval <<-EOV
314
+ def #{relationship}
315
+ UnionCollection.new(#{methods.collect{|m| "self.#{m.to_s}"}.join(',')})
316
+ end
317
+ EOV
318
+ end
319
+ end
320
+ end
321
+ end
322
+
323
+ ActiveRecord::Base.send :include, ActsAsNetwork::Network
324
+ ActiveRecord::Base.send :include, ActsAsNetwork::Union