root/trunk/lib/node_query.rb

Revision 1261, 14.9 kB (checked in by gaspard, 2 months ago)

commit a2950c7c01d7dd489d462e7d4a44323242017ade
Author: Gaspard Bucher <gaspard@teti.ch>

Better modularity for NodeQuery? and added query support for tags ("nodes where tag = 'blue' in site"). #210.

Line 
1 require File.join(File.dirname(__FILE__) , 'query_builder', 'lib', 'query_builder')
2 require 'yaml'
3
4 class NodeQuery < QueryBuilder
5   attr_reader :context, :uses_node_name, :node_name
6   set_main_table 'nodes'
7   set_main_class 'Node'
8   @@filter_fields = {'id' => {:key => 'zip'}}
9  
10   load_custom_queries File.join(File.dirname(__FILE__), 'custom_queries')
11  
12   def self.add_filter_field(key, fld_def)
13     @@filter_fields[key] = fld_def
14   end
15  
16  
17  
18   def initialize(query, opts = {})
19     @uses_node_name = false
20     @table_name = 'nodes'
21     @node_name  = opts[:node_name]
22     # list of dyna_attributes keys allready in the filter
23     @dyn_keys   = {}
24     super(query, opts)
25     # Raw filters are statements prepared that should not be further processed except for table_name replacement.
26     parse_raw_filters(opts[:raw_filters])
27   end
28  
29   # Build joins and filters from a relation.
30   def parse_relation(rel, context)
31     # join_relation first so we can overwrite 'class' finders (images) with a relation.
32     unless join_relation(rel, context) || context_relation(rel, context)
33       @errors << "unknown relation '#{rel}'"
34     end
35   end
36  
37   # Default sort order
38   def default_order_clause
39     "position ASC, name ASC"
40   end
41  
42   def default_context_filter
43     'self'
44   end
45  
46   def after_parse
47     @where.unshift "(\#{#{@node_name}.secure_scope('#{table}')})"
48     if @tables.include?('links')
49       @select << "#{table('links')}.id AS link_id, links.status AS l_status, links.comment AS l_comment"
50     elsif @errors_unless_safe_links
51       @errors += @errors_unless_safe_links
52     end
53     @distinct = true if @tables.include?('versions')
54   end
55  
56   # Erb finder used by zafu
57   def finder(count)
58     return 'nil' unless valid?
59     case count
60     when :count
61       "#{node_name}.do_find(:count, \"#{count_sql}\", #{!uses_node_name}, #{main_class})"
62     else
63       "#{node_name}.do_find(#{count.inspect}, \"#{to_sql}\", #{!uses_node_name}, #{main_class})"
64     end
65   end
66  
67   private
68     # Make sure all alternate queries include "links.id = -1" (dummy link)
69     def fix_where_list(where_list)
70       return unless @tables.include?('links')
71       where_list.each do |f|
72         unless f =~ /links\./
73           f << " AND links.id = -1"
74         end
75       end
76       true
77     end
78    
79     # Used to resolve 'in' clauses ('in project', 'in parent', etc)
80     def context_filter_fields(rel, is_last = false)
81       case rel
82       when 'self'
83         ['parent_id', 'id']
84       when 'parent'
85         is_last ? ['parent_id', 'parent_id'] : ['parent_id', 'id']
86       when 'project'
87         is_last ? ['project_id', 'project_id'] : ['project_id', 'id']
88       when 'section'
89         is_last ? ['section_id', 'section_id'] : ['section_id', 'id']
90       when 'site', main_table
91         :void
92       else
93         nil
94       end
95     end
96    
97     # Relations that can be resolved without a join
98     def context_relation(rel, context)
99       case rel
100       when 'self'
101         fields = ['id', 'id']
102       when 'parent', 'project', 'section'
103         fields = ['id', "#{rel}_id"]
104       when 'parents', 'projects', 'sections'
105         if @table_counter[main_table] > 0 || @tables.include?('links')
106           fields = ['id', "#{rel[0..-2]}_id"]
107         end
108       when 'root'
109         @where << "#{table}.id = #{current_site.root_id}"
110         return true
111       when 'author', 'traductions', 'versions'
112         # TODO: not implemented yet...
113         return nil
114       when 'visitor'
115         @where << "#{table}.id = \#{visitor.contact_id}"
116         return true
117       end
118      
119       unless fields
120         if klass = Node.get_class(rel)
121           parse_context(default_context_filter) unless context
122           @where << "#{table}.kpath LIKE '#{klass.kpath}%'"
123           return true
124         else
125           # unknown class
126           return nil
127         end
128       end
129      
130       @where << "#{field_or_attr(fields[0])} = #{field_or_attr(fields[1], table(main_table,-1))}"
131       true
132     end
133    
134     def parse_change_class(rel, is_last)
135       case rel
136       when 'comment', 'comments'
137         if is_last
138           # no need to load discussions, versions and all the mess
139           add_table('comments')
140           @where << "#{table('comments')}.discussion_id = #{map_attr('discussion_id')}"
141           return CommentQuery # class change
142         else
143           # parse_context(default_context_filter, true) if is_last
144           # after_parse
145           add_table('discussions')
146           add_table('comments')
147           @where << "#{table('discussions')}.node_id = #{table('nodes')}.id"
148           @where << "#{table('comments')}.discussion_id = #{table('discussions')}.id"
149           after_parse
150           return CommentQuery
151         end
152       else
153         return nil
154       end
155     end
156    
157     # Filters that need a join
158     def join_relation(rel, context)
159       if rel == main_table || rel == 'children'
160         # dummy clauses
161         parse_context(default_context_filter) unless context
162         return :void
163       end
164      
165       if rel = RelationProxy.find_by_role(rel.singularize)
166         # We cannot use a LEFT JOIN here because it will totally mess up if we merge alternate queries
167         add_table('links')
168         # (= other_side = result) target <-- source (= link_side = caller)
169         if context && context != 'self'
170           # tagged in project (not equal to 'tagged from nodes in project')
171           # remove caller join
172           @distinct = true
173           @where << "#{field_or_attr('id')} = #{table('links')}.#{rel.other_side} AND #{table('links')}.relation_id = #{rel[:id]}"
174         else
175           @where << "#{field_or_attr('id')} = #{table('links')}.#{rel.other_side} AND #{table('links')}.relation_id = #{rel[:id]} AND #{table('links')}.#{rel.link_side} = #{field_or_attr('id', table(main_table,-1))}"
176         end
177       else
178         nil
179       end
180     end
181    
182     def map_literal(value, env = :sql)
183       if value =~ /(.*?)\[(visitor|param):(\w+)\](.*)/
184         val_start = $1 == '' ? '' : "#{$1.inspect} +"
185         val_end   = $4 == '' ? '' : "+ #{$4.inspect}"
186         case $2
187         when 'visitor'
188           value = env == :sql ? "\#{Node.connection.quote(\#{#{val_start}Node.zafu_attribute(visitor.contact, #{$3.inspect})#{val_end}})}" : nil
189         when 'param'
190           value = env == :sql ? "\#{Node.connection.quote(#{val_start}params[:#{$3}].to_s#{val_end})}" : "params[:#{$3}]"
191         end
192       else
193         value = env == :sql ? Node.connection.quote(value) : nil
194       end
195     end
196    
197     # Translate fields used for query/sort/grouping (context parameter) into something useable by SQL. Add the appropriate tables when needed.
198     def map_field(field, table_name = table, context = nil)
199       return map_literal("[#{field}]") if field =~ /\Aparam:/
200       case field[0..1]
201       when 'd_'
202         # DYNAMIC ATTRIBUTE
203         key = field[2..-1]
204         key, function = parse_sql_function_in_field(key)
205         key = function ? "#{function}(#{dyn_value('versions', key, context)})" : dyn_value('versions', key, context)
206       when 'c_'
207         # CONTENT TABLE
208         field = field[2..-1]
209         # FIXME: implement #41
210         nil
211       when 'v_'
212         # VERSION
213         key = field[2..-1]
214         key, function = parse_sql_function_in_field(key)
215         if Version.zafu_readable?(key) && Version.column_names.include?(key)
216           vtable_name = needs_table('nodes', 'versions', "TABLE1.id = TABLE2.node_id")
217           key = function ? "#{function}(#{vtable_name}.#{key})" : "#{vtable_name}.#{key}"
218         else
219           # bad version attribute
220           nil
221         end
222       when 'l_' 
223         key, function = parse_sql_function_in_field(field)
224         if key == 'l_status' || key == 'l_comment' || (key == 'l_id' && [:order, :group].include?(context))
225           @errors_unless_safe_links ||= []
226           @errors_unless_safe_links << "cannot use link field '#{key}' in this query" unless (key == 'l_id' && context == :order)
227           # ok
228           function ? "#{function}(#{table('links')}.#{key[2..-1]})" : "#{table('links')}.#{key[2..-1]}"
229         else
230           # bad attribute
231           nil
232         end
233       else
234         # NODE
235         key, function = parse_sql_function_in_field(field)
236         if context == :filter
237           if map_def = @@filter_fields[key]
238             if table_def = map_def[:table]
239               table_to_use = needs_table(*table_def)
240             else
241               table_to_use = table_name
242             end
243             function ? "#{function}(#{table_to_use}.#{map_def[:key]})" : "#{table_to_use}.#{map_def[:key]}"
244           elsif (Node.zafu_readable?(key) && Node.column_names.include?(key))
245             function ? "#{function}(#{table_name}.#{key})" : "#{table_name}.#{key}"
246           else
247             nil
248           end
249         else
250           if ['id', 'parent_id','project_id','section_id'].include?(key) || (Node.zafu_readable?(key) && Node.column_names.include?(key))
251             function ? "#{function}(#{table_name}.#{key})" : "#{table_name}.#{key}"
252           else
253             # bad attribute
254             nil
255           end
256         end
257       end
258     end
259    
260     def valid_field?(table_name, fld)
261       # FIXME: security !
262       true
263     end
264    
265     def map_attr(fld, env = :sql)
266       case fld
267       when 'project_id', 'section_id', 'discussion_id'
268         @uses_node_name = true
269         "\#{#{@node_name}.get_#{fld}}"
270       when 'id', 'parent_id'
271         @uses_node_name = true
272         "\#{#{@node_name}.#{fld}}"
273       else 
274         # Node.zafu_readable?(fld)
275         # bad parameter
276         @errors << "invalid parameter '#{fld}'"
277         "0"
278       end
279     end
280    
281     def parse_paginate_clause(paginate)
282       return @offset unless paginate
283       if !@limit
284         # TODO: raise error ?
285         @errors << "invalid paginate clause '#{paginate}' (used without limit)"
286         nil
287       elsif (fld = map_literal("[param:#{paginate}]", :ruby)) && (page_size = @limit[/ LIMIT (\d+)/,1])
288         @page_size = [2,page_size.to_i].max
289         " OFFSET \#{((#{fld}.to_i > 0 ? #{fld}.to_i : 1)-1)*#{page_size.to_i}}"
290       else
291         @errors << "invalid paginate clause '#{paginate}'"
292         nil
293       end
294     end
295    
296     # When a field is defined as log_at:year, return [log_at, year].
297     def parse_sql_function_in_field(field)
298       if field =~ /\A(\w+):(\w+)\Z/
299         if ['year'].include?($2)
300           [$1,$2]
301         else
302           [$1]
303         end
304       else
305         [field]
306       end
307     end
308    
309     def parse_raw_filters(filters)
310       return unless filters
311       filters.each do |f|
312         @where << f.gsub("TABLE_NAME", table)
313       end
314     end
315    
316     def dyn_value(table_name, key, context)
317       @dyn_keys[table_name] ||= {}
318       @dyn_keys[table_name][key] ||= begin
319         needs_table('nodes', 'versions', "TABLE1.id = TABLE2.node_id")
320         dtable = needs_join_table('versions', 'LEFT', 'dyn_attributes', "TABLE1.id = TABLE2.owner_id AND TABLE2.key = '#{key.gsub(/[^a-z_A-Z]/,'')}'", "versions=dyn_attributes=#{key}")
321         "#{dtable}.value"
322       end
323     end
324        
325     def parse_custom_query_argument(key, value)
326       return nil unless value
327       super.gsub(/(RELATION_ID|NODE_ATTR)\(([^)]+)\)/) do
328         type, value = $1, $2
329         if type == 'RELATION_ID'
330           role = value
331           if rel = RelationProxy.find_by_role(role.singularize)
332             rel[:id]
333           else
334             @errors << "could not find Relation '#{role}' in custom query"
335             '-1'
336           end
337         elsif type == 'NODE_ATTR'
338           attribute = value
339           if Node.zafu_readable?(attribute)
340             "\#{Node.connection.quote(#{@node_name}.#{attribute})}"
341           else
342             @errors << "cannot read attribute '#{attribute}' in custom query"
343             '-1'
344           end
345         end
346       end.gsub(/NODE_ID/) do
347         @uses_node_name = true
348         "\#{#{@node_name}.id}"
349       end
350     end
351    
352     def extract_custom_query(list)
353       super.singularize
354     end
355 end
356
357
358
359 module Zena
360   module Query
361     module UseNodeQuery
362       # this is called when the module is included into the 'base' module
363       def self.included(base)
364         # add all methods from the module "AddActsAsMethod" to the 'base' module
365         base.extend Zena::Query::ClassMethods
366       end
367     end
368    
369     module ClassMethods
370       def use_node_query
371         class_eval <<-END
372         include Zena::Query::InstanceMethods
373         END
374       end
375      
376       # Return an sql query string that will be used by 'do_find':
377       # build_find(:all, PSEUDO_SQL, node_name) => "SELECT * FROM nodes WHERE nodes.parent_id = #{@node[:id]} AND ..."
378       # PSEUDO_SQL: what to find in pseudo sql (See NodeQuery for details).
379       # node_name: contextual variable name
380       #
381       # Pseudo sql syntax:
382       #
383       # '[CLASS|VCLASS|RELATION] [in [site|section|project|parent]|] [where CLAUSE|] [from SUB_QUERY|] or [PSEUDO_SQL]'
384       #
385       # with :
386       #   * CLASS:  a native class ('images', 'documents', 'pages', 'projects', ...)
387       #   * VCLASS: a virtual class created by the user ('posts', 'houses', ...)
388       #   * RELATION: a relation defined by the user ('icon_for', 'news', 'calendar', ...)
389       #   * CLAUSE: field = value ('log_at:year = 2005'). You can use parameters, visitor data in clause: 'log_at:year = [param:year]', 'd_assigned = [visitor:name]'. You can only use 'and' in clauses. 'or' is not supported. You can use version and/or dynamic attributes : 'v_comment = super', 'd_priority = low'.
390       #
391       # Examples: 'todos in section where d_priority = high and d_assigned = [visitor:name]'
392       #def build_find(count, pseudo_sql, node_name, raw_filters = nil, ignore_warnings = false, ref_date = nil)
393       def build_find(count, pseudo_sql, opts = {})
394         if count == :first
395           opts[:limit] = 1
396         end
397         NodeQuery.new(pseudo_sql, opts.merge(:custom_query_group => visitor.site.host))
398       end
399     end
400    
401
402     module InstanceMethods
403      
404       # Find a node and propagate visitor
405       def do_find(count, query, ignore_source = false, klass = Node)
406         return nil if query.empty?
407         return nil if (new_record? && !ignore_source) # do not run query (might contain nil id)
408         
409         case count
410         when :all
411           res = klass.find_by_sql(query)
412           if res == []
413             nil
414           else
415             res.each {|r| visitor.visit(r)}
416             res
417           end
418         when :first
419           res = klass.find_by_sql(query).first
420           visitor.visit(res) if res
421           res
422         when :count
423           klass.count_by_sql(query)
424         else
425           nil
426         end
427       end
428      
429       # Find related nodes.
430       # See Node#build_find for details on the options available.
431       def find(count, rel)
432         rel = [rel] if rel.kind_of?(String)
433        
434         if rel.size == 1 && self.class.zafu_known_contexts[rel.first]
435           self.send(rel.first)
436         else
437           sql = Node.build_find(count, rel, :node_name => 'self').to_sql
438           if sql
439             do_find(count, eval("\"#{sql}\""))
440           else
441             nil
442           end
443         end
444       end
445     end
446   end
447 end
448  
449 ActiveRecord::Base.send :include, Zena::Query::UseNodeQuery
Note: See TracBrowser for help on using the browser.