querybuilder 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
data/History.txt ADDED
@@ -0,0 +1,4 @@
1
+ == 0.5.0 2009-01-23
2
+
3
+ * 1 major enhancement:
4
+ * Initial release (extraction from zena: http://zenadmin.org)
data/Manifest.txt ADDED
@@ -0,0 +1,19 @@
1
+ History.txt
2
+ Manifest.txt
3
+ README.rdoc
4
+ Rakefile
5
+ lib/QueryBuilder.rb
6
+ script/console
7
+ script/destroy
8
+ script/generate
9
+ test/mock/custom_queries
10
+ test/mock/custom_queries/test.yml
11
+ test/mock/dummy_query.rb
12
+ test/mock/user_query.rb
13
+ test/QueryBuilder/basic.yml
14
+ test/QueryBuilder/errors.yml
15
+ test/QueryBuilder/filters.yml
16
+ test/QueryBuilder/joins.yml
17
+ test/QueryBuilder/mixed.yml
18
+ test/test_helper.rb
19
+ test/test_QueryBuilder.rb
data/README.rdoc ADDED
@@ -0,0 +1,67 @@
1
+ = querybuilder
2
+
3
+ * http://github.com/zena/querybuilder/tree/master
4
+
5
+ == DESCRIPTION:
6
+
7
+ QueryBuilder is an interpreter for the "pseudo sql" language. This language
8
+ can be used for two purposes:
9
+
10
+ 1. protect your database from illegal SQL by securing queries
11
+ 2. ease writing complex relational queries by abstracting table internals
12
+
13
+ == SYNOPSIS:
14
+
15
+ # Create your own query class (DummyQuery) to parse your specific models (see test/mock).
16
+
17
+ # Compile a query:
18
+ query = DummyQuery.new("images where name like '%flower%' from favorites")
19
+
20
+ # Get compilation result:
21
+ query.to_s
22
+ => "['SELECT ... FROM ... WHERE links.source_id = ?', @node.id]"
23
+
24
+ # Evaluate bind variables (produces executable SQL):
25
+ query.sql(binding)
26
+ => "SELECT ... FROM ... WHERE links.source_id = 1234"
27
+
28
+ # Compile to get count instead of records:
29
+ query.to_s(:count)
30
+ => "['SELECT COUNT(*) ... WHERE links.source_id = ?', @node.id]"
31
+
32
+ query.sql(binding, :count)
33
+ => "SELECT COUNT(*) ... WHERE links.source_id = 1234"
34
+
35
+
36
+ == REQUIREMENTS:
37
+
38
+ * yamltest
39
+
40
+ == INSTALL:
41
+
42
+ sudo gem install querybuilder
43
+
44
+ == LICENSE:
45
+
46
+ (The MIT License)
47
+
48
+ Copyright (c) 2008-2009 Gaspard Bucher
49
+
50
+ Permission is hereby granted, free of charge, to any person obtaining
51
+ a copy of this software and associated documentation files (the
52
+ 'Software'), to deal in the Software without restriction, including
53
+ without limitation the rights to use, copy, modify, merge, publish,
54
+ distribute, sublicense, and/or sell copies of the Software, and to
55
+ permit persons to whom the Software is furnished to do so, subject to
56
+ the following conditions:
57
+
58
+ The above copyright notice and this permission notice shall be
59
+ included in all copies or substantial portions of the Software.
60
+
61
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
62
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
63
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
64
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
65
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
66
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
67
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,27 @@
1
+ %w[rubygems rake rake/clean fileutils newgem rubigen].each { |f| require f }
2
+ require File.dirname(__FILE__) + '/lib/QueryBuilder'
3
+
4
+ # Generate all the Rake tasks
5
+ # Run 'rake -T' to see list of generated tasks (from gem root directory)
6
+ $hoe = Hoe.new('querybuilder', QueryBuilder::VERSION) do |p|
7
+ p.developer('Gaspard Bucher', 'gaspard@teti.ch')
8
+ p.changes = p.paragraphs_of("History.txt", 0..1).join("\n\n")
9
+ p.rubyforge_name = 'querybuilder'
10
+ p.extra_deps = [
11
+ ['yamltest','>= 0.5.0'],
12
+ ]
13
+ p.extra_dev_deps = [
14
+ ['newgem', ">= #{::Newgem::VERSION}"]
15
+ ]
16
+
17
+ p.clean_globs |= %w[**/.DS_Store tmp *.log]
18
+ path = (p.rubyforge_name == p.name) ? p.rubyforge_name : "\#{p.rubyforge_name}/\#{p.name}"
19
+ p.remote_rdoc_dir = File.join(path.gsub(/^#{p.rubyforge_name}\/?/,''), 'rdoc')
20
+ p.rsync_args = '-av --delete --ignore-errors'
21
+ end
22
+
23
+ require 'newgem/tasks' # load /tasks/*.rake
24
+ Dir['tasks/**/*.rake'].each { |t| load t }
25
+
26
+ # TODO - want other tests/tasks run by default? Add them to the list
27
+ # task :default => [:spec, :features]
@@ -0,0 +1,881 @@
1
+ $:.unshift(File.dirname(__FILE__)) unless
2
+ $:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__)))
3
+
4
+ require 'yaml'
5
+
6
+ =begin rdoc
7
+ QueryBuilder is a tool to secure and simplify the creation of SQL queries from untrusted users.
8
+
9
+ Syntax of a query is "RELATION [where ...|] [in ...|from SUB_QUERY|]".
10
+ =end
11
+ class QueryBuilder
12
+ attr_reader :tables, :where, :errors, :join_tables, :distinct, :final_parser, :page_size
13
+ VERSION = '0.5.0'
14
+
15
+ @@main_table = {}
16
+ @@main_class = {}
17
+ @@custom_queries = {}
18
+
19
+ class << self
20
+ # This is the table name of the main class.
21
+ def set_main_table(table_name)
22
+ @@main_table[self] = table_name.to_s
23
+ end
24
+
25
+ # This is the class of the returned elements if there is no class change in the query. This
26
+ # should correspond to the class used to build call "Foo.find_by_sql(...)" (Foo).
27
+ def set_main_class(main_class)
28
+ @@main_class[self] = main_class.to_s
29
+ end
30
+
31
+ # Load prepared SQL definitions from a directory.
32
+ #
33
+ # ==== Parameters
34
+ # query<String>:: Path to list of custom queries yaml files.
35
+ #
36
+ # ==== Examples
37
+ # DummyQuery.load_custom_queries("/path/to/directory")
38
+ #
39
+ # The format of a custom query definition is:
40
+ #
41
+ # DummyQuery: # QueryBuilder class
42
+ # abc: # query's relation name
43
+ # select: # selected fields
44
+ # - 'a'
45
+ # - '34 AS number'
46
+ # - 'c'
47
+ # tables: # tables used
48
+ # - 'test'
49
+ # where: # filters
50
+ # - '1'
51
+ # - '2'
52
+ # - '3'
53
+ # order: 'a DESC' # order clause
54
+ #
55
+ # Once loaded, this 'custom query' can be used in a query like:
56
+ # "images from abc where a > 54"
57
+ def load_custom_queries(dir)
58
+ klass = nil
59
+ if File.directory?(dir)
60
+ Dir.foreach(dir) do |file|
61
+ next unless file =~ /(.+).yml$/
62
+ custom_query_group = $1
63
+ definitions = YAML::load(File.read(File.join(dir,file)))
64
+ definitions.each do |klass,v|
65
+ klass = Module.const_get(klass)
66
+ raise ArgumentError.new("invalid class for CustomQueries (#{klass})") unless klass.ancestors.include?(QueryBuilder)
67
+ @@custom_queries[klass] ||= {}
68
+ @@custom_queries[klass][custom_query_group] ||= {}
69
+ klass_queries = @@custom_queries[klass][custom_query_group]
70
+ v.each do |k,v|
71
+ klass_queries[k] = v
72
+ end
73
+ end
74
+ end
75
+ end
76
+ rescue NameError => err
77
+ raise ArgumentError.new("invalid class for CustomQueries (#{klass})")
78
+ end
79
+
80
+ # Return the parser built from the query. The class of the returned object can be different
81
+ # from the class used to call "new". For example: NodeQuery.new("comments from nodes in project") would
82
+ # return a CommentQuery since that is the final fetched objects (final_parser).
83
+ #
84
+ # ==== Parameters
85
+ # query<String>:: Pseudo sql query string.
86
+ # opts<Hash>:: List of options.
87
+ # * custom_query_group<String>:: Name of 'yaml' custom query to use (eg. 'test' for 'test.yml')
88
+ # * skip_after_parse<Boolean>:: If true, skip 'after_parse' method.
89
+ # * ignore_warnings<Boolean>:: If true, the query will always succeed (returns a dummy query instead of nil).
90
+ #
91
+ # ==== Returns
92
+ # QueryBuilder:: A query builder subclass object.
93
+ # The object can be invalid if there were errors found during compilation.
94
+ #
95
+ # ==== Examples
96
+ # DummyQuery.new("objects in project order by name ASC, id DESC", :custom_query_group => 'test')
97
+ #
98
+ def new(query, opts = {})
99
+ obj = super(query, opts)
100
+ obj.final_parser
101
+ end
102
+ end
103
+
104
+ # Build a new query from a pseudo sql string. See QueryBuilder::new for details.
105
+ def initialize(query, opts = {})
106
+ if opts[:pre_query]
107
+ init_with_pre_query(opts[:pre_query], opts[:elements])
108
+ else
109
+ init_with_query(query, opts)
110
+ end
111
+
112
+ parse_elements(@elements)
113
+ end
114
+
115
+ # Convert query object to a string. This string should then be evaluated.
116
+ #
117
+ # ==== Parameters
118
+ # type<Symbol>:: Type of query to build (:find or :count).
119
+ #
120
+ # ==== Returns
121
+ # NilClass:: If the query is not valid and "ignore_warnings" was not set to true during initialize.
122
+ # String:: A string representing the query with its bind parameters.
123
+ #
124
+ # ==== Examples
125
+ # query.to_s
126
+ # => "[\"SELECT objects.* FROM objects WHERE objects.project_id = ?\", project_id]"
127
+ #
128
+ # DummyQuery.new("nodes in site").to_s
129
+ # => "\"SELECT objects.* FROM objects\""
130
+ #
131
+ # query.to_s(:count)
132
+ # => "[\"SELECT COUNT(*) FROM objects WHERE objects.project_id = ?\", project_id]"
133
+ def to_s(type = :find)
134
+ return nil if !valid?
135
+ return "\"SELECT #{@main_table}.* FROM #{@main_table} WHERE 0\"" if @tables.empty? # all alternate queries invalid and 'ignore_warnings' set.
136
+ statement, bind_values = build_statement(type)
137
+ bind_values.empty? ? "\"#{statement}\"" : "[#{[["\"#{statement}\""] + bind_values].join(', ')}]"
138
+ end
139
+
140
+ # Convert the query object into an SQL query.
141
+ #
142
+ # ==== Parameters
143
+ # bindings<Binding>:: Binding context in which to evaluate bind clauses (query arguments).
144
+ # type<Symbol>:: Type of SQL query (:find or :count)
145
+ #
146
+ # ==== Returns
147
+ # NilClass:: If the query is not valid and "ignore_warnings" was not set to true during initialize.
148
+ # String:: An SQL query, ready for execution (no more bind variables).
149
+ #
150
+ # ==== Examples
151
+ # query.sql(binding)
152
+ # => "SELECT objects.* FROM objects WHERE objects.project_id = 12489"
153
+ #
154
+ # query.sql(bindings, :count)
155
+ # => "SELECT COUNT(*) FROM objects WHERE objects.project_id = 12489"
156
+ def sql(bindings, type = :find)
157
+ return nil if !valid?
158
+ return "SELECT #{@main_table}.* FROM #{@main_table} WHERE 0" if @tables.empty? # all alternate queries invalid and 'ignore_warnings' set.
159
+ statement, bind_values = build_statement(type)
160
+ connection = get_connection(bindings)
161
+ statement.gsub('?') { eval_bound_value(bind_values.shift, connection, bindings) }
162
+ end
163
+
164
+
165
+ # Test query validity
166
+ #
167
+ # ==== Returns
168
+ # TrueClass:: True if object is valid.
169
+ def valid?
170
+ @errors == []
171
+ end
172
+
173
+ # Name of the pagination key when 'paginate' is used.
174
+ #
175
+ # ==== Parameters
176
+ # parameters
177
+ #
178
+ # ==== Returns
179
+ # String:: Pagination key name.
180
+ #
181
+ # ==== Examples
182
+ # DummyQuery.new("objects in site limit 5 paginate pak").pagination_key
183
+ # => "pak"
184
+ def pagination_key
185
+ @offset_limit_order_group[:paginate]
186
+ end
187
+
188
+ # Main class for the query (useful when queries move from class to class)
189
+ #
190
+ # ==== Returns
191
+ # Class:: Class of element
192
+ #
193
+ # ==== Examples
194
+ # DummyQuery.new("comments from nodes in project").main_class
195
+ # => Comment
196
+ def main_class
197
+ Module.const_get(@@main_class[self.class])
198
+ end
199
+
200
+ protected
201
+
202
+ def current_table
203
+ @current_table || main_table
204
+ end
205
+
206
+ def main_table
207
+ @@main_table[self.class]
208
+ end
209
+
210
+ def parse_part(part, is_last)
211
+
212
+ rest, context = part.split(' in ')
213
+ clause, filters = rest.split(/\s+where\s+/)
214
+
215
+ if @just_changed_class
216
+ # just changed class: parse filters && context
217
+ parse_filters(filters) if filters
218
+ @just_changed_class = false
219
+ return nil
220
+ elsif new_class = parse_change_class(clause, is_last)
221
+ if context
222
+ last_filter = @where.pop # pop/push is to keep queries in correct order (helps reading sql)
223
+ parse_context(context, true)
224
+ @where << last_filter
225
+ end
226
+ return new_class
227
+ else
228
+ add_table(main_table)
229
+ parse_filters(filters) if filters
230
+ parse_context(context, is_last) if context # .. in project
231
+ parse_relation(clause, context)
232
+ return nil
233
+ end
234
+ end
235
+
236
+ def parse_filters(clause)
237
+ # TODO: add 'match' parameter (#105)
238
+ rest = clause.strip
239
+ types = [:par_open, :value, :bool_op, :op, :par_close]
240
+ allowed = [:par_open, :value]
241
+ after_value = [:op, :bool_op, :par_close]
242
+ par_count = 0
243
+ last_bool_op = ''
244
+ has_or = false
245
+ res = ""
246
+ while rest != ''
247
+ # puts rest.inspect
248
+ if rest =~ /\A\s+/
249
+ rest = rest[$&.size..-1]
250
+ res << " "
251
+ elsif rest[0..0] == '('
252
+ unless allowed.include?(:par_open)
253
+ @errors << clause_error(clause, rest, res)
254
+ return
255
+ end
256
+ res << '('
257
+ rest = rest[1..-1]
258
+ par_count += 1
259
+ elsif rest[0..0] == ')'
260
+ unless allowed.include?(:par_close)
261
+ @errors << clause_error(clause, rest, res)
262
+ return
263
+ end
264
+ res << ')'
265
+ rest = rest[1..-1]
266
+ par_count -= 1
267
+ if par_count < 0
268
+ @errors << clause_error(clause, rest, res)
269
+ return
270
+ end
271
+ allowed = [:op, :bool_op]
272
+ elsif rest =~ /\A((>=|<=|<>|<|=|>)|((not\s+like|like|lt|le|eq|ne|ge|gt)\s+))/
273
+ unless allowed.include?(:op)
274
+ @errors << clause_error(clause, rest, res)
275
+ return
276
+ end
277
+ op = $1.strip
278
+ rest = rest[op.size..-1]
279
+ op = {'lt' => '<','le' => '<=','eq' => '=','ne' => '<>','ge' => '>=','gt' => '>','like' => 'LIKE', 'not like' => 'NOT LIKE'}[op] || $1
280
+ res << op
281
+ allowed = [:value, :par_open]
282
+ elsif rest =~ /\A("|')([^\1]*?)\1/
283
+ unless allowed.include?(:value)
284
+ @errors << clause_error(clause, rest, res)
285
+ return
286
+ end
287
+ rest = rest[$&.size..-1]
288
+ res << map_literal($2)
289
+ allowed = after_value
290
+ elsif rest =~ /\A(\d+|[\w:]+)\s+(second|minute|hour|day|week|month|year)s?/
291
+ unless allowed.include?(:value)
292
+ @errors << clause_error(clause, rest, res)
293
+ return
294
+ end
295
+ rest = rest[$&.size..-1]
296
+ fld, type = $1, $2
297
+ unless field = field_or_attr(fld, table, :filter)
298
+ @errors << "invalid field or value #{fld.inspect}"
299
+ return
300
+ end
301
+ res << "INTERVAL #{field} #{type.upcase}"
302
+ allowed = after_value
303
+ elsif rest =~ /\A(-?\d+)/
304
+ unless allowed.include?(:value)
305
+ @errors << clause_error(clause, rest, res)
306
+ return
307
+ end
308
+ rest = rest[$&.size..-1]
309
+ res << $1
310
+ allowed = after_value
311
+ elsif rest =~ /\A(is\s+not\s+null|is\s+null)/
312
+ unless allowed.include?(:bool_op)
313
+ @errors << clause_error(clause, rest, res)
314
+ return
315
+ end
316
+ rest = rest[$&.size..-1]
317
+ res << $1.upcase
318
+ allowed = [:par_close, :bool_op]
319
+ elsif rest[0..7] == 'REF_DATE'
320
+ unless allowed.include?(:value)
321
+ @errors << clause_error(clause, rest, res)
322
+ return
323
+ end
324
+ rest = rest[8..-1]
325
+ res << @ref_date
326
+ allowed = after_value
327
+ elsif rest =~ /\A(\+|\-)/
328
+ unless allowed.include?(:op)
329
+ @errors << clause_error(clause, rest, res)
330
+ return
331
+ end
332
+ rest = rest[$&.size..-1]
333
+ res << $1
334
+ allowed = [:value, :par_open]
335
+ elsif rest =~ /\A(and|or)/
336
+ unless allowed.include?(:bool_op)
337
+ @errors << clause_error(clause, rest, res)
338
+ return
339
+ end
340
+ rest = rest[$&.size..-1]
341
+ res << $1.upcase
342
+ has_or ||= $1 == 'or'
343
+ allowed = [:par_open, :value]
344
+ elsif rest =~ /\A[\w:]+/
345
+ unless allowed.include?(:value)
346
+ @errors << clause_error(clause, rest, res)
347
+ return
348
+ end
349
+ rest = rest[$&.size..-1]
350
+ fld = $&
351
+ unless field = field_or_attr(fld, table, :filter)
352
+ @errors << "invalid field or value #{fld.inspect}"
353
+ return
354
+ end
355
+ res << field
356
+ allowed = after_value
357
+ else
358
+ @errors << clause_error(clause, rest, res)
359
+ return
360
+ end
361
+ end
362
+
363
+ if par_count > 0
364
+ @errors << "invalid clause #{clause.inspect}: missing closing ')'"
365
+ elsif allowed.include?(:value)
366
+ @errors << "include clause #{clause.inspect}"
367
+ else
368
+ @where << (has_or ? "(#{res})" : res)
369
+ end
370
+ end
371
+
372
+ def parse_order_clause(order)
373
+ return @order unless order
374
+ res = []
375
+
376
+ order.split(',').each do |clause|
377
+ if clause == 'random'
378
+ res << "RAND()"
379
+ else
380
+ if clause =~ /^\s*([^\s]+) (ASC|asc|DESC|desc)/
381
+ fld_name, direction = $1, $2
382
+ else
383
+ fld_name = clause
384
+ direction = 'ASC'
385
+ end
386
+ if fld = field_or_attr(fld_name, table, :order)
387
+ res << "#{fld} #{direction.upcase}"
388
+ elsif fld.nil?
389
+ @errors << "invalid field '#{fld_name}'"
390
+ end
391
+ end
392
+ end
393
+ res == [] ? nil : " ORDER BY #{res.join(', ')}"
394
+ end
395
+
396
+ def parse_group_clause(group)
397
+ return @group unless group
398
+ res = []
399
+
400
+ group.split(',').each do |field|
401
+ if fld = map_field(field, table, :group)
402
+ res << fld
403
+ else
404
+ @errors << "invalid field '#{field}'"
405
+ end
406
+ end
407
+ res == [] ? nil : " GROUP BY #{res.join(', ')}"
408
+ end
409
+
410
+ def parse_limit_clause(limit)
411
+ return @limit unless limit
412
+ if limit.kind_of?(Fixnum)
413
+ " LIMIT #{limit}"
414
+ elsif limit =~ /^\s*(\d+)\s*,\s*(\d+)/
415
+ @offset = " OFFSET #{$1}"
416
+ " LIMIT #{$2}"
417
+ elsif limit =~ /(\d+)/
418
+ " LIMIT #{$1}"
419
+ else
420
+ @errors << "invalid limit clause '#{limit}'"
421
+ nil
422
+ end
423
+ end
424
+
425
+ def parse_paginate_clause(paginate)
426
+ return @offset unless paginate
427
+ if !@limit
428
+ # TODO: raise error ?
429
+ @errors << "invalid paginate clause '#{paginate}' (used without limit)"
430
+ nil
431
+ elsif (fld = map_literal(paginate, :ruby)) && (page_size = @limit[/ LIMIT (\d+)/,1])
432
+ @page_size = [2,page_size.to_i].max
433
+ " OFFSET #{insert_bind("((#{fld}.to_i > 0 ? #{fld}.to_i : 1)-1)*#{@page_size}")}"
434
+ else
435
+ @errors << "invalid paginate clause '#{paginate}'"
436
+ nil
437
+ end
438
+ end
439
+
440
+ def parse_offset_clause(offset)
441
+ return @offset unless offset
442
+ if !@limit
443
+ # TODO: raise error ?
444
+ @errors << "invalid offset clause '#{offset}' (used without limit)"
445
+ nil
446
+ elsif offset.strip =~ /^\d+$/
447
+ " OFFSET #{offset}"
448
+ else
449
+ @errors << "invalid offset clause '#{offset}'"
450
+ nil
451
+ end
452
+ end
453
+
454
+ def add_table(table_name)
455
+ if !@table_counter[table_name]
456
+ @table_counter[table_name] = 0
457
+ @tables << table_name
458
+ else
459
+ @table_counter[table_name] += 1
460
+ @tables << "#{table_name} AS #{table(table_name)}"
461
+ end
462
+ end
463
+
464
+ # return a unique table name for the current sub-query context, adding the table when necessary
465
+ def needs_table(table1, table2, filter)
466
+ @needed_tables[table2] ||= {}
467
+ @needed_tables[table2][table] ||= begin
468
+ add_table(table2)
469
+ @where << filter.gsub('TABLE1', table).gsub('TABLE2', table(table2))
470
+ table(table2)
471
+ end
472
+ end
473
+
474
+ # versions LEFT JOIN dyn_attributes ON ...
475
+ def needs_join_table(table1, type, table2, clause, join_name = nil)
476
+ join_name ||= "#{table1}=#{type}=#{table2}"
477
+ @needed_join_tables[join_name] ||= {}
478
+ @needed_join_tables[join_name][table] ||= begin
479
+ # define join for this part ('table' = unique for each part)
480
+
481
+ first_table = table(table1)
482
+
483
+ if !@table_counter[table2]
484
+ @table_counter[table2] = 0
485
+ second_table = table2
486
+ else
487
+ @table_counter[table2] += 1
488
+ second_table = "#{table2} AS #{table(table2)}"
489
+ end
490
+ @join_tables[first_table] ||= []
491
+ @join_tables[first_table] << "#{type} JOIN #{second_table} ON #{clause.gsub('TABLE1',table(table1)).gsub('TABLE2',table(table2))}"
492
+ table(table2)
493
+ end
494
+ end
495
+
496
+ def table_counter(table_name)
497
+ @table_counter[table_name] || 0
498
+ end
499
+
500
+ def table_at(table_name, index)
501
+ if index < 0
502
+ return nil # no table at this address
503
+ end
504
+ index == 0 ? table_name : "#{table_name[0..1]}#{index}"
505
+ end
506
+
507
+ def table(table_name=main_table, index=0)
508
+ table_at(table_name, table_counter(table_name) + index)
509
+ end
510
+
511
+ def merge_alternate_queries(alt_queries)
512
+ counter = 1
513
+ if valid?
514
+ counter = 1
515
+ else
516
+ if @ignore_warnings
517
+ # reset current query
518
+ @tables = []
519
+ @join_tables = {}
520
+ @where = []
521
+ @errors = []
522
+ @distinct = nil
523
+ end
524
+ counter = 0
525
+ end
526
+
527
+ if @where.compact == []
528
+ where_list = []
529
+ else
530
+ where_list = [@where.compact.reverse.join(' AND ')]
531
+ end
532
+
533
+ alt_queries.each do |query|
534
+ next unless query.main_class == self.main_class # no mixed class target !
535
+ @errors += query.errors unless @ignore_warnings
536
+ next unless query.valid?
537
+ query.where.compact!
538
+ next if query.where.empty?
539
+ counter += 1
540
+ merge_tables(query)
541
+ @distinct ||= query.distinct
542
+ where_list << query.where.reverse.join(' AND ')
543
+ end
544
+
545
+ @where_list = where_list
546
+
547
+ @tables.uniq!
548
+
549
+ fix_where_list(where_list)
550
+
551
+ if counter > 1
552
+ @distinct = @tables.size > 1
553
+ @where = ["((#{where_list.join(') OR (')}))"]
554
+ else
555
+ @where = where_list
556
+ end
557
+ end
558
+
559
+ def merge_tables(sub_query)
560
+ @tables += sub_query.tables
561
+ sub_query.join_tables.each do |k,v|
562
+ @join_tables[k] ||= []
563
+ @join_tables[k] << v
564
+ end
565
+ end
566
+
567
+ def prepare_custom_query_arguments(key, value)
568
+ if value.kind_of?(Array)
569
+ value.map {|e| parse_custom_query_argument(key, e)}
570
+ elsif value.kind_of?(Hash)
571
+ value.each do |k,v|
572
+ if v.kind_of?(Array)
573
+ value[k] = v.map {|e| parse_custom_query_argument(key, e)}
574
+ else
575
+ value[k] = parse_custom_query_argument(key, v)
576
+ end
577
+ end
578
+ else
579
+ parse_custom_query_argument(key, value)
580
+ end
581
+ end
582
+
583
+ # Map a field to be used inside a query. An attr is a field from table at index 0 = @node attribute.
584
+ def field_or_attr(fld, table_name = table, context = nil)
585
+ if fld =~ /^\d+$/
586
+ return fld
587
+ elsif @select.join =~ / AS #{fld}/
588
+ @select.each do |s|
589
+ if s =~ /\A(.*) AS #{fld}\Z/
590
+ return context == :filter ? "(#{$1})" : fld
591
+ end
592
+ end
593
+ elsif table_name
594
+ map_field(fld, table_name, context)
595
+ else
596
+ map_attr(fld)
597
+ end
598
+ end
599
+
600
+ def build_statement(type = :find)
601
+ statement = type == :find ? find_statement : count_statement
602
+
603
+ # get bind variables
604
+ bind_values = []
605
+ statement.gsub!(/\[\[(.*?)\]\]/) do
606
+ bind_values << $1
607
+ '?'
608
+ end
609
+ [statement, bind_values]
610
+ end
611
+
612
+ def find_statement
613
+ table_list = []
614
+ @tables.each do |t|
615
+ table_name = t.split(/\s+/).last # objects AS ob1
616
+ if joins = @join_tables[table_name]
617
+ table_list << "#{t} #{joins.join(' ')}"
618
+ else
619
+ table_list << t
620
+ end
621
+ end
622
+
623
+ group = @group
624
+ if !group && @distinct
625
+ group = @tables.size > 1 ? " GROUP BY #{table}.id" : " GROUP BY id"
626
+ end
627
+
628
+
629
+ "SELECT #{@select.join(',')} FROM #{table_list.flatten.join(',')}" + (@where == [] ? '' : " WHERE #{@where.reverse.join(' AND ')}") + group.to_s + @order.to_s + @limit.to_s + @offset.to_s
630
+ end
631
+
632
+ def count_statement
633
+ table_list = []
634
+ @tables.each do |t|
635
+ table_name = t.split(/\s+/).last # objects AS ob1
636
+ if joins = @join_tables[table_name]
637
+ table_list << "#{t} #{joins.join(' ')}"
638
+ else
639
+ table_list << t
640
+ end
641
+ end
642
+
643
+ if @group =~ /GROUP\s+BY\s+(.+)/
644
+ # we need to COALESCE in order to count groups where $1 is NULL.
645
+ count_on = "COUNT(DISTINCT COALESCE(#{$1},0))"
646
+ elsif @distinct
647
+ count_on = "COUNT(DISTINCT #{table}.id)"
648
+ else
649
+ count_on = "COUNT(*)"
650
+ end
651
+
652
+ "SELECT #{count_on} FROM #{table_list.flatten.join(',')}" + (@where == [] ? '' : " WHERE #{@where.reverse.join(' AND ')}")
653
+ end
654
+
655
+ # Adapted from Rail's ActiveRecord code. We need "eval" because
656
+ # QueryBuilder is a compiler and it has absolutely no knowledge
657
+ # of the running context.
658
+ def eval_bound_value(value_as_string, connection, bindings)
659
+ value = eval(value_as_string, bindings)
660
+ if value.respond_to?(:map) && !value.kind_of?(String) #!value.acts_like?(:string)
661
+ if value.respond_to?(:empty?) && value.empty?
662
+ connection.quote(nil)
663
+ else
664
+ value.map { |v| connection.quote(v) }.join(',')
665
+ end
666
+ else
667
+ connection.quote(value)
668
+ end
669
+ end
670
+
671
+ def get_connection(bindings)
672
+ eval "#{main_class}.connection", bindings
673
+ end
674
+
675
+ # ******** Overwrite these **********
676
+ def class_from_table(table_name)
677
+ Object
678
+ end
679
+
680
+ def default_context_filter
681
+ raise NameError.new("default_context_filter not defined for class #{self.class}")
682
+ end
683
+
684
+ # Default sort order
685
+ def default_order_clause
686
+ nil
687
+ end
688
+
689
+ def after_parse
690
+ # do nothing
691
+ end
692
+
693
+ def parse_change_class(rel, is_last)
694
+ nil
695
+ end
696
+
697
+ def parse_relation(clause, context)
698
+ return nil
699
+ end
700
+
701
+ def context_filter_fields(clause, is_last = false)
702
+ nil
703
+ end
704
+
705
+ def parse_context(clause, is_last = false)
706
+
707
+ if fields = context_filter_fields(clause, is_last)
708
+ @where << "#{field_or_attr(fields[0])} = #{field_or_attr(fields[1], table(main_table,-1))}" if fields != :void
709
+ else
710
+ @errors << "invalid context '#{clause}'"
711
+ end
712
+ end
713
+
714
+ # Map a litteral value to be used inside a query
715
+ def map_literal(value, env = :sql)
716
+ env == :sql ? insert_bind(value.inspect) : value
717
+ end
718
+
719
+
720
+ # Overwrite this and take car to check for valid fields.
721
+ def map_field(fld, table_name, context = nil)
722
+ if fld == 'id'
723
+ "#{table_name}.#{fld}"
724
+ else
725
+ # TODO: error, raise / ignore ?
726
+ end
727
+ end
728
+
729
+ def map_attr(fld)
730
+ insert_bind(fld.to_s)
731
+ end
732
+
733
+ # ******** And maybe overwrite these **********
734
+ def parse_custom_query_argument(key, value)
735
+ return nil unless value
736
+ value = value.gsub('REF_DATE', @ref_date)
737
+ case key
738
+ when :order
739
+ " ORDER BY #{value}"
740
+ when :group
741
+ " GROUP BY #{value}"
742
+ else
743
+ value
744
+ end
745
+ end
746
+
747
+ def extract_custom_query(list)
748
+ list[-1].split(' ').first
749
+ end
750
+
751
+ private
752
+
753
+ def parse_elements(elements)
754
+ # "final_parser" is the parser who will respond to 'to_sql'. It might be a sub-parser for another class.
755
+ @final_parser = self
756
+
757
+ if @@custom_queries[self.class] &&
758
+ @@custom_queries[self.class][@opts[:custom_query_group]] &&
759
+ custom_query = @@custom_queries[self.class][@opts[:custom_query_group]][extract_custom_query(elements)]
760
+ custom_query.each do |k,v|
761
+ instance_variable_set("@#{k}", prepare_custom_query_arguments(k.to_sym, v))
762
+ end
763
+ # set table counters
764
+ @tables.each do |t|
765
+ base, as, tbl = t.split(' ')
766
+ @table_counter[base] ||= 0
767
+ @table_counter[base] += 1 if tbl
768
+ end
769
+ # parse filters
770
+ clause, filters = elements[-1].split(/\s+where\s+/)
771
+
772
+ parse_filters(filters) if filters
773
+ @order = parse_order_clause(@offset_limit_order_group[:order])
774
+ else
775
+ i, new_class = 0, nil
776
+ elements.each_index do |i|
777
+ break if new_class = parse_part(elements[i], i == 0) # yes, is_last is first (parsing reverse)
778
+ end
779
+
780
+ if new_class
781
+ # move to another parser class
782
+ @final_parser = new_class.new(nil, :pre_query => self, :elements => elements[i..-1])
783
+ else
784
+ @distinct ||= elements.size > 1
785
+ @select << "#{table}.*"
786
+
787
+ merge_alternate_queries(@alt_queries) if @alt_queries
788
+
789
+ @limit = parse_limit_clause(@offset_limit_order_group[:limit])
790
+ if @offset_limit_order_group[:paginate]
791
+ @offset = parse_paginate_clause(@offset_limit_order_group[:paginate])
792
+ else
793
+ @offset = parse_offset_clause(@offset_limit_order_group[:offset])
794
+ end
795
+
796
+
797
+ @group = parse_group_clause(@offset_limit_order_group[:group])
798
+ @order = parse_order_clause(@offset_limit_order_group[:order] || default_order_clause)
799
+ end
800
+ end
801
+
802
+ if @final_parser == self
803
+ after_parse unless @opts[:skip_after_parse]
804
+ @where.compact!
805
+ end
806
+ end
807
+
808
+ def init_with_query(query, opts)
809
+ @opts = opts
810
+
811
+ if query.kind_of?(Array)
812
+ @query = query[0]
813
+ if query.size > 1
814
+ @alt_queries = query[1..-1].map {|q| self.class.new(q, opts.merge(:skip_after_parse => true))}
815
+ end
816
+ else
817
+ @query = query
818
+ end
819
+
820
+
821
+ @offset_limit_order_group = {}
822
+ if @query == nil || @query == ''
823
+ elements = [main_table]
824
+ else
825
+ elements = @query.split(' from ')
826
+ last_element = elements.last
827
+ last_element, @offset_limit_order_group[:offset] = last_element.split(' offset ')
828
+ last_element, @offset_limit_order_group[:paginate] = last_element.split(' paginate ')
829
+ last_element, @offset_limit_order_group[:limit] = last_element.split(' limit ')
830
+ last_element, @offset_limit_order_group[:order] = last_element.split(' order by ')
831
+ elements[-1], @offset_limit_order_group[:group] = last_element.split(' group by ')
832
+ end
833
+
834
+ @offset_limit_order_group[:limit] = opts[:limit] || @offset_limit_order_group[:limit]
835
+ # In order to know the table names of the dependencies, we need to parse it backwards.
836
+ # We first find the closest elements, then the final ones. For example, "pages from project" we need
837
+ # project information before getting 'pages'.
838
+ @elements = elements.reverse
839
+
840
+ @tables = []
841
+ @join_tables = {}
842
+ @table_counter = {}
843
+ @where = []
844
+ # list of tables that need to be added for filter clauses (should be added only once per part)
845
+ @needed_tables = {}
846
+ # list of tables that need to be added through a join (should be added only once per part)
847
+ @needed_join_tables = {}
848
+
849
+ @errors = []
850
+
851
+ @main_table ||= 'objects'
852
+
853
+ @select = []
854
+
855
+ @ignore_warnings = opts[:ignore_warnings]
856
+
857
+ @ref_date = opts[:ref_date] ? "'#{opts[:ref_date]}'" : 'now()'
858
+ end
859
+
860
+ def init_with_pre_query(pre_query, elements)
861
+ pre_query.instance_variables.each do |iv|
862
+ next if iv == '@query' || iv == '@final_parser'
863
+ instance_variable_set(iv, pre_query.instance_variable_get(iv))
864
+ end
865
+ @just_changed_class = true
866
+ @elements = elements
867
+ end
868
+
869
+ def clause_error(clause, rest, res)
870
+ "invalid clause #{clause.inspect} near \"#{res[-2..-1]}#{rest[0..1]}\""
871
+ end
872
+
873
+ def insert_bind(str)
874
+ "[[#{str}]]"
875
+ end
876
+
877
+ # Make sure all clauses are compatible (where_list is a list of strings, not arrays)
878
+ def fix_where_list(where_list)
879
+ # do nothing
880
+ end
881
+ end