querybuilder 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- data/History.txt +4 -0
- data/Manifest.txt +19 -0
- data/README.rdoc +67 -0
- data/Rakefile +27 -0
- data/lib/QueryBuilder.rb +881 -0
- data/script/console +10 -0
- data/script/destroy +14 -0
- data/script/generate +14 -0
- data/test/QueryBuilder/basic.yml +66 -0
- data/test/QueryBuilder/errors.yml +50 -0
- data/test/QueryBuilder/filters.yml +43 -0
- data/test/QueryBuilder/joins.yml +25 -0
- data/test/QueryBuilder/mixed.yml +12 -0
- data/test/mock/custom_queries/test.yml +14 -0
- data/test/mock/dummy_query.rb +114 -0
- data/test/mock/user_query.rb +55 -0
- data/test/test_QueryBuilder.rb +36 -0
- data/test/test_helper.rb +7 -0
- metadata +105 -0
data/History.txt
ADDED
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]
|
data/lib/QueryBuilder.rb
ADDED
@@ -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
|