acts_as_network 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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