root/trunk/lib/linkable.rb

Revision 970, 19.3 kB (checked in by gaspard, 8 months ago)

Implemented link status and comment. Closes #195(8). Started to cleanup links controller.

Line 
1 module Zena
2   module Acts
3 =begin rdoc
4 Linkable provides the 'link' macro.
5
6 This macro works with a 'links' table with 'role', 'source_id' and 'target_id' fields (plus 'id' auto increment). With this
7 set, you can create 'roles' between objects. For example if you have a class Person, you could add
8   class Person < ActiveRecord::Base
9     link :wife,    :class_name=>'Person', :unique=>true, :as_unique=>true
10     link :husband, :class_name=>'Person', :unique=>true, :as=>'wife', :as_unique=>true
11   end
12
13 This creates the following methods for your Person objects:
14   @john.wife                   ==> finds wife
15   @mary.husband                ==> find husband
16   @john.wife = @mary           ==> set wife
17   @john.wife_id = @mary.id     ==> set wife by id
18   @mary.husband = @john        ==> set husband
19   @mary.husband_id = @john.id  ==> set husband by id
20  
21 Because of the ":as" clause, 'husbands' and 'wifes' are related together, so
22   @john.wife = @mary   ==> @mary.husband gives @john
23
24 The ":unique" and ":as_unique" clauses make sure @john has only one wife and @mary has only one husband
25
26 Another example with the infamous 'tags'
27   class Post < ActiveRecord::Base
28     link :tags, :class_name=>'Tag'
29   end
30
31   class Tag < ActiveRecord::Base
32     link :posts, :class_name=>'Post', :as=>'tag'
33   end
34
35 This gives the posts the following methods:
36 @post.tags          ==> list of tags
37 This is a finder. It can be used as @post.tags(:conditions=>...). There is an additional option
38 for an 'OR' clause in case you need to 'merge' linked objects with direct children. Example :
39   @post.tags(:or=>["parent_id = ?", self[:id]]) # this does a single call, so ':order, :limit' and pagination
40   work just fine...
41
42   @post.tag_ids       ==> list of tag ids
43   @post.tags = ...    ==> set with list of tag objects
44   @post.tag_ids = ... ==> set with list of ids
45   @post.add_tag(id)
46   @post.remove_tag(id)
47
48 And the tags get :
49   @tag.posts          ==> list of posts
50   @tag.post_ids       ==> list of post ids
51   @tag.posts = ...    ==> set with list of post objects
52   @tag.post_ids = ... ==> set with list of ids
53   @tag.add_post(id)
54   @tag.remove_post(id)
55  
56 As an extra, you get 'tags_for_form' and 'posts_for_form' : a list of all 'tags' or 'posts' with the attribute 'link_id' not null if
57 the two objects are linked. Example :
58   @post.tags_for_form = ['art object with link_id=nil', 'news object with link_id=3'] ==> @post has a link to news. *Beware* that this finder will *only* find objects which are of the same kind or subclasses of the class of the linked object (Tag here)
59
60 Linkable is great for single table inheritance and lots of 'roles' between classes. It is also very easy to create a new role like
61 'hot' topic for example. Having the hottest post on each project is easy as adding a check box on the post edit page and adding
62 the 'hot' roles :
63
64   class Section < ActiveRecord::Base
65     link :hot, :class_name=>'Post', :unique=>true
66   end
67
68   class Post < ActiveRecord::Base
69     link :hot_for, :class_name=>'Section', :as_unique=>true, :unique=>true
70   end
71
72 on the post edit page :
73   <input type="checkbox" id="post_hot_for_id" name="post[hot_for_id]" value="<%= @project.id %>" />
74
75 =end
76     module Linkable
77       class << self
78         def plural_method?(method)
79           m = method.split('_').first
80           m.pluralize == m || method.ends_with?('_for')
81         end
82       end
83       # this is called when the module is included into the 'base' module
84       def self.included(base)
85         # add all methods from the module "AddActsAsMethod" to the 'base' module
86         base.extend AddActsAsMethod
87       end
88      
89       # List the links, grouped by role
90       def role_links
91         res = []
92         self.class.roles.each do |role|
93           if role[:collector]
94             limit = 5
95           else
96             limit = nil
97           end
98           links = secure!(Node) { Node.find(:all,
99                           :select     => "#{Node.table_name}.*,links.id AS link_id, links.status AS l_status, links.comment AS l_comment, links.role",
100                           :joins      => "INNER JOIN links ON #{Node.table_name}.id=links.#{role[:other_side]}",
101                           :conditions => ["links.#{role[:link_side]} = ? AND links.role = ?", self[:id], role[:role] ],
102                           :order => "link_id DESC",
103                           :limit => limit )} || []
104           res << [role, links] if links != []
105         end
106         res
107       end
108      
109       # add a link without passing by normal set/remove (this is used in forms when 'role' is used as a parameter)
110       def add_link(method, other_id)
111         role = nil
112         method = method.to_s
113         role = self.class.role[method]
114         unless role
115           errors.add(method, 'not a correct method')
116           return false
117         end
118         sym = nil
119         if role[:unique]
120           sym = "#{method}_id=".to_sym
121         else
122           sym = "add_#{method.singularize}".to_sym
123         end
124         self.send(sym, other_id)
125         return true
126       end
127      
128       def link=(hash)
129         add_link(hash['role'], hash['other_id'])
130       end
131      
132       # remove a link
133       def remove_link(link_id)
134         link = Link.find(link_id)
135         if link[:source_id] == self[:id]
136           link_side = 'source_id'
137           other_id  = link[:target_id]
138         elsif link[:target_id] == self[:id]
139           link_side = 'target_id'
140           other_id  = link[:source_id]
141         else
142           raise ActiveRecord::RecordNotFound, "Bad link id"
143         end
144         role = link[:role]
145         link = nil
146         self.class.roles.each do |r|
147           if r[:role] == role && r[:link_side] == link_side
148             link = r
149             break
150           end
151         end
152         unless link
153           raise ActiveRecord::RecordNotFound, "Bad link id"
154         end
155         if link[:unique]
156           sym = "#{link[:method]}_id=".to_sym
157           self.send(sym, nil)
158         else
159           sym = "remove_#{link[:method].singularize}".to_sym
160           self.send(sym, other_id)
161         end
162         return true
163       end
164      
165       # calls the method defined with link. This is a wrapper used by templating systems to avoid calling arbitrary methods
166       def relation(method)
167         return nil unless self.class.role[method]
168         self.send(method.to_sym)
169       end
170      
171       # FIXME: cleanup needed!
172       def fetch_link(link_name, options)
173         return nil unless link_def = self.class.defined_role[link_name]
174         return nil unless options[:from] || self.class.role[link_name]
175         klass      = link_def[:class]
176         klass      = Module.const_get(link_def[:class].to_sym) if klass.kind_of?(String)
177         link_side  = link_def[:link_side]
178         other_side = link_def[:other_side]
179         role       = link_def[:role]
180         count      = link_def[:count]
181        
182         conditions = options.delete(:conditions)
183         direction  = options.delete(:direction)
184        
185         # :from
186         side_cond = ""
187         params    = []
188         case options[:from]
189         when 'site' 
190           count = :all
191         when 'project'
192           if conditions.kind_of?(Array)
193             conditions[0] = "(#{conditions[0]}) AND section_id = ?"
194             conditions << self[:section_id]
195           elsif conditions
196             conditions = ["(#{conditions}) AND section_id = ?", self[:section_id]]
197           else
198             conditions = ["section_id = ?", self[:section_id]]
199           end
200           count = :all
201         else
202           if direction == 'both'
203             side_cond = " AND (links.#{link_side} = ? OR links.#{other_side} = ?) AND (nodes.id <> ? OR links.#{other_side} = links.#{link_side})"
204             params = [self[:id]] * 3
205           else
206             side_cond = " AND links.#{link_side} = ?"
207             params = [self[:id]]
208           end
209         end
210         options.delete(:from)
211        
212         if direction == 'both'
213           join_direction = "(#{klass.table_name}.id=links.#{other_side} OR #{klass.table_name}.id=links.#{link_side})"
214         else
215           join_direction = "#{klass.table_name}.id=links.#{other_side}"
216         end
217        
218         if options[:or]
219           join = 'LEFT'
220           if options[:or].kind_of?(Array)
221             or_clause = options[:or].shift
222             params.unshift(options[:or])
223           else
224             or_clause = options[:or]
225           end 
226           inner_conditions = ["(#{or_clause}) OR (links.role='#{role}'#{side_cond} AND links.id IS NOT NULL)", *params ]
227           options.delete(:or)
228         else
229           join = 'INNER'
230           inner_conditions = ["links.role='#{role}'#{side_cond}", *params ]
231         end
232         options.merge!( :select     => "#{klass.table_name}.*,links.id AS link_id, links.status AS l_status, links.comment AS l_comment, links.role",
233                         :joins      => "#{join} JOIN links ON #{join_direction}",
234                         :conditions => inner_conditions,
235                         :group      => 'id'
236                         )
237         if conditions
238           klass.with_scope(:find=>{:conditions=>conditions}) do
239             secure(klass) { klass.find(count, options ) }
240           end
241         else
242           secure(klass) { klass.find(count, options ) }
243         end
244       end
245      
246       module AddActsAsMethod
247         @@role          = {}
248         @@roles         = {}
249         @@roles_for_class = {}
250         @@defined_role = {}
251        
252         # list of links defined for this class (with superclass)
253         def roles
254           @@roles[self] ||= role.to_a.sort.map{|k,v| v}
255         end
256        
257         def defined_role
258           @@defined_role
259         end
260        
261         # hash with the links defined for this class (with superclass)
262         def role
263           @@role[self] ||= if superclass == ActiveRecord::Base
264             @@roles_for_class[self] || {}
265           else
266             superclass.role.merge(@@roles_for_class[self] || {})
267           end
268         end
269        
270         def roles_for_form
271           roles.map {|r| [r[:method].singularize, r[:method]] }
272         end
273        
274         # Look at Zena::Acts::Linkable for documentation.
275         # Fixme: this is not used anymore...
276         def link(method, options={})
277           method = method.to_s
278           unless method_defined?(:secure) || private_method_defined?(:secure)
279             # define dummy 'secure' and 'secure_write' to work out of Zena
280             class_eval "def secure!(*args); yield; end"
281             class_eval "def secure_write!(*args); yield; end"
282           end
283           @@roles_for_class[self] ||= {}
284           class_name = options[:class_name] || method.singularize.capitalize
285           if options[:for] || options[:as]
286             link_side  = 'target_id'
287             other_side = 'source_id'
288           else
289             link_side  = 'source_id'
290             other_side = 'target_id'
291           end
292           if options[:unique]
293             count = :first
294             role = (options[:as] || method.downcase).to_s
295           else
296             count = :all
297             role = (options[:as] || method.downcase.singularize).to_s
298           end
299           link_def = { :method=>method, :role=>role, :link_side=>link_side, :other_side=>other_side, :unique=>(options[:unique] == true), :collector=>(options[:collector] == true), :class=>class_name, :count=>count }
300          
301           @@roles_for_class[self][method] = link_def
302           @@defined_role[method] = link_def
303           finder = <<-END
304             def #{method}(options={})
305               fetch_link(#{method.inspect}, options)
306             end
307           END
308           class_eval finder
309           unless method_defined?(:destroy_links) || private_method_defined?(:destroy_links)
310             after_destroy :destroy_links
311             class_eval <<-END
312               def destroy_links
313                 self.class.connection.execute("DELETE FROM links WHERE source_id = \#{self[:id]} OR target_id = \#{self[:id]}")
314               end
315             END
316           end
317          
318           if options[:as_unique]
319             destroy_if_as_unique     = <<-END
320             if link2 = Link.find_by_role_and_#{other_side}('#{role}', obj_id)
321               errors.add('#{role}', 'can not destroy') unless link2.destroy
322             end
323             END
324             find_target = 'secure_drive'
325           else
326             destroy_if_as_unique = ""
327             find_target = 'secure_write'
328           end
329          
330           if options[:unique]
331             methods = <<-END
332               def #{method}_id=(obj_id); @#{method}_id = obj_id; end
333               def #{method}=(obj); @#{method}_id = obj.id; end
334               def #{method}_id
335                 link = Link.find_by_role_and_#{link_side}('#{role}', self[:id])
336                 link ? link[:#{other_side}] : nil
337               end
338              
339               def #{method}_zip
340                 fetch_link(#{method.inspect})[:zip]
341               end
342              
343               # link can be changed if user can write in old and new
344               # 1. can remove old link
345               # 2. can write in new target
346               def validate_#{method}
347                 return unless defined? @#{method}_id
348                
349                 # 1. can remove old link ?
350                 if link = Link.find_by_role_and_#{link_side}('#{role}', self[:id])
351                   obj_id = link.#{other_side}
352                   unless #{find_target}(#{class_name}) { #{class_name}.find(obj_id) }
353                     errors.add('#{role}', 'cannot remove old link')
354                   end
355                 end
356                
357                 # 2. can write in new target ?
358                 obj_id = @#{method}_id
359                 if obj_id && obj_id != ''
360                   # set
361                   unless #{find_target}(#{class_name}) { #{class_name}.find(obj_id) } # make sure we can write in the object
362                     errors.add('#{role}', 'invalid')
363                   end
364                 end
365               end
366              
367               def save_#{method}
368                 return unless defined? @#{method}_id
369                 obj_id = @#{method}_id
370                 if obj_id && obj_id != ''
371                   # set
372                   obj_id = obj_id.to_i
373                   if link = Link.find_by_role_and_#{link_side}('#{role}', self[:id])
374                     #{destroy_if_as_unique}
375                     link.#{other_side} = obj_id
376                   else
377                     #{destroy_if_as_unique}
378                     link = Link.new(:#{link_side}=>self[:id], :#{other_side}=>obj_id, :role=>"#{role}")
379                   end 
380                   errors.add('#{role}', 'could not be set') unless link.save
381                 else
382                   # remove
383                   if link = Link.find_by_role_and_#{link_side}('#{role}', self[:id])
384                     errors.add('#{role}', 'could not be removed') unless link.destroy
385                   end
386                 end
387                 remove_instance_variable :@#{method}_id
388                 return errors.empty?
389               end
390             END
391           else
392             # multiple
393             meth = method.singularize
394             methods = <<-END
395               def #{meth}_ids=(obj_ids)
396                 @#{meth}_ids = obj_ids ? obj_ids.map{|i| i.to_i} : []
397               end
398               # add a single element
399               def #{meth}_id=(obj_id)
400                 @#{meth}_ids = #{meth}_ids + [obj_id]
401               end
402               def #{method}=(objs)
403                 @#{meth}_ids = objs ? objs.map{|obj| obj[:id]} : []
404               end
405               def #{meth}_ids; res = #{method}; res ? res.map{|r| r[:id]} : []; end
406               def #{meth}_zips; res = #{method}; res ? res.map{|r| r[:zip]}.join(', ') : ''; end
407              
408               # link can be changed if user can write in old and new
409               # 1. can remove old links
410               # 2. can write in new targets
411               def validate_#{method}
412                 return unless defined? @#{meth}_ids
413                 unless @#{meth}_ids.kind_of?(Array)
414                   errors.add('#{role}', 'bad format')
415                   return false
416                 end
417                 # what changed ?
418                 obj_ids = @#{meth}_ids.map{|i| i.to_i }
419                 del_ids = []
420                 # find all current links
421                 (#{method} || []).each do |link|
422                   obj_id = link[:id]
423                   unless obj_ids.include?(obj_id)
424                     del_ids << obj_id
425                   end
426                   obj_ids.delete(obj_id) # ignore existing links
427                 end
428                 @#{meth}_add_ids = obj_ids
429                 @#{meth}_del_ids = del_ids
430                
431                 # 1. can remove old link ?
432                 @#{meth}_del_ids.each do |obj_id|
433                   unless #{find_target}(#{class_name}) { #{class_name}.find(obj_id) }
434                     errors.add('#{role}', 'cannot remove link')
435                   end
436                 end
437                
438                 # 2. can write in new target ?
439                 @#{meth}_add_ids.each do |obj_id|
440                   unless #{find_target}(#{class_name}) { #{class_name}.find(obj_id) }
441                     errors.add('#{meth}', 'invalid target')
442                   end
443                 end
444                
445               end
446              
447               def save_#{method}
448                 return true unless defined? @#{meth}_ids
449                
450                 if @#{meth}_del_ids && (obj_ids = @#{meth}_del_ids) != []
451                   # remove all old links for this role
452                   links = Link.find(:all, :conditions => ["links.role='#{role}' AND links.#{link_side} = ? AND links.#{other_side} IN (\#{obj_ids.join(',')})", self[:id] ])
453                   links.each do |l|
454                     errors.add('#{role}', 'could not be removed') unless l.destroy
455                   end
456                 end
457                
458                 if @#{meth}_add_ids && (obj_ids = @#{meth}_add_ids) != []
459                   # add new links for this role
460                   obj_ids.each do |obj_id|
461                     #{destroy_if_as_unique}
462                     errors.add('#{role}', 'could not be set') unless Link.create(:#{link_side}=>self[:id], :#{other_side}=>obj_id, :role=>"#{role}")
463                   end
464                 end
465                 remove_instance_variable :@#{meth}_ids
466                 return errors.empty?
467               end
468              
469               def remove_#{meth}(obj_id)
470                 @#{meth}_ids ||= #{meth}_ids || []
471                 # ignore bad obj_ids, just pass
472                 @#{meth}_ids.delete(obj_id.to_i)
473                 return true
474               end
475              
476               def add_#{meth}(obj_id)
477                 @#{meth}_ids ||= #{meth}_ids || []
478                 @#{meth}_ids << obj_id.to_i unless @#{meth}_ids.include?(obj_id.to_i)
479                 return true
480               end
481              
482               def #{method}_for_form(options={})
483                 options.merge!( :select     => "\#{#{class_name}.table_name}.*,links.id AS link_id, links.status AS l_status, links.comment AS l_comment, links.role",
484                                 :joins      => "LEFT OUTER JOIN links ON \#{#{class_name}.table_name}.id=links.#{other_side} AND links.role='#{role}' AND links.#{link_side} = \#{self[:id].to_i}"
485                                 )
486                 #{find_target}(#{class_name}) { #{class_name}.find(:all, options) }
487               rescue ActiveRecord::RecordNotFound
488                 []
489               end
490                
491             END
492           end
493           class_eval methods
494           validate     "validate_#{method}".to_sym
495           after_save   "save_#{method}".to_sym
496         end
497       end
498     end
499   end
500 end
501
502 ActiveRecord::Base.send :include, Zena::Acts::Linkable
Note: See TracBrowser for help on using the browser.