safrano 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,447 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'odata/error.rb'
4
+
5
+ # a few helper method
6
+ class String
7
+ # MASK_RGX = /'([^']*)'/.freeze
8
+ MASK_RGX = /'((?:[^']|(?:\'{2}))*)'/.freeze
9
+ # QUOTED_RGX = /'((?:[^']|(?:\'{2}))*)'/.freeze
10
+ UNMASK_RGX = /'(%?)(\$\d+)(%?)'/.freeze
11
+ def with_mask_quoted_substrings!
12
+ cnt = 0
13
+ repl = {}
14
+ gsub!(MASK_RGX) do |_m|
15
+ cnt += 1
16
+ repl["$#{cnt}"] = Regexp.last_match(1)
17
+ "'$#{cnt}'"
18
+ end
19
+ yield self
20
+
21
+ gsub!(UNMASK_RGX) do |_m|
22
+ k = Regexp.last_match(2).to_s
23
+ "'#{Regexp.last_match(1)}#{repl[k]}#{Regexp.last_match(3)}'"
24
+ end
25
+ end
26
+
27
+ def with_mask_quoted_substrings
28
+ cnt = 0
29
+ repl = {}
30
+ tmpstr = gsub(MASK_RGX) do |_m|
31
+ cnt += 1
32
+ repl["$#{cnt}"] = Regexp.last_match(1)
33
+ "'$#{cnt}'"
34
+ end
35
+ yield tmpstr
36
+ # tmpstr2.gsub(UNMASK_RGX) do |_m|
37
+ # k = Regexp.last_match(2).to_s
38
+ # "'#{Regexp.last_match(1)}#{repl[k]}#{Regexp.last_match(3)}'"
39
+ # end
40
+ end
41
+ end
42
+
43
+ # Handles filtering with ruby expressions
44
+ # (eval and DB full scan used --> better avoid using this)
45
+ module FilterWithRuby
46
+ # this module requires the @fn attribute to exist where it is used
47
+ def fn=(farg)
48
+ @fn = farg
49
+ @fn_tab = farg.split('/').map(&:to_sym)
50
+ end
51
+
52
+ # returns the attribute named "@fn" of object inp. This version assumes that
53
+ # @fn can be a path like "address/city"
54
+ def get_value(inp, colescev = '')
55
+ obj = inp
56
+ @fn_tab.each do |csymb|
57
+ tmp = obj.send(csymb)
58
+ if tmp.nil?
59
+ obj = colescev
60
+ break
61
+ else
62
+ obj = tmp
63
+ end
64
+ end
65
+ obj
66
+ end
67
+ end
68
+
69
+ # filter base class and subclass in our OData namespace
70
+ module OData
71
+ # handles associations in filter arguments
72
+ module FilterWithAssoc
73
+ def get_assoc
74
+ @assoc, @fn = @fn.split('/') if @fn.include?('/')
75
+ end
76
+
77
+ def get_qualified_fn(dtcx)
78
+ seqtn = if dtcx.respond_to? :table_name
79
+ @assoc ? @assoc.to_sym : dtcx.table_name
80
+ else
81
+ @assoc ? @assoc.to_sym : dtcx.model.table_name
82
+ end
83
+ Sequel[seqtn][@fn.to_sym]
84
+ end
85
+ end
86
+
87
+ # base class for filtering
88
+ class Filter
89
+ attr_reader :assoc
90
+ attr_reader :assocs
91
+ QUOTED_RGX = /'((?:[^']|(?:\'{2}))*)'/.freeze
92
+ NOTQ_RGX = /((?:[^'\s\)])*)/.freeze
93
+
94
+ def initialize(matx)
95
+ @matx = matx
96
+ @assocs = Set.new
97
+ end
98
+
99
+ class << self
100
+ attr_reader :regexp
101
+ end
102
+
103
+ # input : the filter string
104
+ # returns a filter object that should have a apply_to(cx) method
105
+ def self.new_by_parse(filterstr, dtset = nil)
106
+ # handle complex flat expressions (flat = without relationships)
107
+ # with Sequel
108
+ ComplexFilter.new(filterstr, dtset)
109
+ end
110
+
111
+ # try to handle a simple expression like < fieldname EQ value >
112
+ # or < substringof(a,b) >
113
+ # NO parenthesis and no AND/OR handled here
114
+ def self.new_full_match(filterstr)
115
+ full_reg = Regexp.new(/\A#{regexp}\z/)
116
+ # this needs to return nil, not false, or some testcase will fail
117
+ return nil unless (matx = filterstr.match(full_reg))
118
+
119
+ # actually it's self.new(matx)
120
+ new(matx)
121
+ end
122
+
123
+ # handle complex deep (relations) expressions with Ruby
124
+ def self.new_full_match_complexpr_by_ruby(filterstr)
125
+ ComplexFilterByRuby.new(filterstr)
126
+ end
127
+
128
+ def apply_to_dataset(dtcx)
129
+ dtcx
130
+ end
131
+
132
+ def apply_associations(dtcx)
133
+ @assocs.each { |aj| dtcx = dtcx.association_join(aj) }
134
+ dtcx
135
+ end
136
+ end
137
+
138
+ # well...
139
+ class EqualFilter < Filter
140
+ include FilterWithRuby
141
+ include FilterWithAssoc
142
+ EQUAL_KW_RGX = '[eE][qQ]|[LlgGNn][eETt]'.freeze
143
+ RIGHT_RGX = %q{'[^']+'|[^'\)\s]+}.freeze
144
+
145
+ def self.qualified_regexp(paths_rx)
146
+ /\s*(#{paths_rx})\s+(#{EQUAL_KW_RGX})\s+(?:#{QUOTED_RGX}|#{NOTQ_RGX})\s*/
147
+ end
148
+
149
+ def initialize(matx)
150
+ super(matx)
151
+ self.fn = matx[1]
152
+ get_assoc if @fn
153
+
154
+ @val = matx[3] ? matx[3].gsub("''", "'") : matx[4]
155
+
156
+ @op = matx[2].downcase
157
+ end
158
+
159
+ # ugly hack... but working
160
+ def gen_sql(dtcx)
161
+ # handle ambiguous column names (joins) by qualifiying the names
162
+ # seqfn = @assoc ? Sequel[@assoc.to_sym][@fn.to_sym] : @fn.to_sym
163
+ # handle ambiguous column names (joins) by qualifiying the names
164
+ # for the main table as well
165
+ # TODO: find a better way to differentiate between the two types
166
+ # of dtcx : Model or Dataset
167
+
168
+ # DONE: same handling for order and substring and everywhere where
169
+ # qualified fieldnames could be needed (uses attrib path regexp)
170
+ x = apply_to_dataset(dtcx)
171
+ # ugly ugly hack :-(
172
+ y = x.unordered.sql.sub(x.unfiltered.unordered.sql + ' WHERE', '')
173
+ y
174
+ end
175
+
176
+ def apply_to_dataset(dtcx)
177
+ seqfn = get_qualified_fn(dtcx)
178
+ # using @val in Procs below does not work reliably. Using a local var
179
+ # seems to work better
180
+ argval = @val
181
+ case @op
182
+ when 'eq'
183
+ dtcx.where(seqfn => @val)
184
+ when 'ne'
185
+ dtcx.exclude(seqfn => @val)
186
+ when 'le'
187
+ dtcx.where { seqfn <= argval }
188
+ when 'ge'
189
+ dtcx.where { seqfn >= argval }
190
+ when 'lt'
191
+ dtcx.where { seqfn < argval }
192
+ when 'gt'
193
+ dtcx.where { seqfn > argval }
194
+ else
195
+ raise OData::FilterParseError
196
+ end
197
+ end
198
+
199
+ def apply_to_object(inp)
200
+ case @op
201
+ when 'eq'
202
+ get_value(inp) == @val
203
+ when 'ne'
204
+ get_value(inp) != @val
205
+ else
206
+ raise OData::FilterParseError
207
+ end
208
+ end
209
+ end
210
+
211
+ # keyword based
212
+ class FilterWithKeyword < Filter
213
+ attr_reader :keyword
214
+ end
215
+
216
+ # for substringof('value', field)
217
+ class SubstringOfFilterSig1 < FilterWithKeyword
218
+ include FilterWithRuby
219
+ include FilterWithAssoc
220
+
221
+ def self.qualified_regexp(paths_rx)
222
+ # Note: QUOTED_RGX captures the string content without
223
+ # start and end quote (no need to un-quote)
224
+ # NOTQ_RGX captures an not-quoted value argument (like for numbers)
225
+ /(substringof)\((?:#{QUOTED_RGX}|#{NOTQ_RGX}),\s*(#{paths_rx})\)/
226
+ end
227
+
228
+ def initialize(matx)
229
+ super(matx)
230
+ # binding.pry
231
+ self.fn = matx[4]
232
+ get_assoc if @fn
233
+ # @val = matx[2].strip_single_quote
234
+ @val = matx[2] ? matx[2].gsub("''", "'") : matx[3]
235
+ @keyword = matx[1]
236
+ end
237
+
238
+ def whcl(dtcx)
239
+ # handle ambiguous column names (joins) by qualifiying the names
240
+ # seqfn = @assoc ? Sequel[@assoc.to_sym][@fn.to_sym] : @fn.to_sym
241
+ seqfn = get_qualified_fn(dtcx)
242
+ Sequel.like(seqfn, "%#{@val}%")
243
+ end
244
+
245
+ def gen_sql(dtcx)
246
+ sql_ = ''
247
+ dt = dtcx.respond_to?(:dataset) ? dtcx.dataset : dtcx
248
+ dt.literal_append(sql_, whcl(dt))
249
+ sql_
250
+ end
251
+
252
+ def apply_to_dataset(dtcx)
253
+ dtcx.where(whcl(dtcx))
254
+ end
255
+
256
+ def apply_to_object(inp)
257
+ get_value(inp).include?(@val)
258
+ end
259
+ end
260
+ # for substringof(field,'value')
261
+ class SubstringOfFilterSig2 < FilterWithKeyword
262
+ include FilterWithRuby
263
+ include FilterWithAssoc
264
+
265
+ def initialize(matx)
266
+ super(matx)
267
+ self.fn = matx[2]
268
+ get_assoc if @fn
269
+ @val = matx[3]
270
+ @keyword = matx[1]
271
+
272
+ raise FilterParseError
273
+ end
274
+
275
+ def apply_to_dataset(dtcx)
276
+ dtcx
277
+ end
278
+
279
+ def apply_to_object(inp)
280
+ get_value(inp).include?(@val)
281
+ end
282
+ end
283
+
284
+ # Filter by start/end with
285
+ class StartOrEndsWithFilter < FilterWithKeyword
286
+ include FilterWithRuby
287
+ include FilterWithAssoc
288
+ # DONE: better handle quotes vs not-quotes. Unblanced quotes are now handled
289
+ # example : startswith(year, 190')
290
+
291
+ def self.qualified_regexp(paths)
292
+ /(endswith|startswith)\((#{paths}),\s*(?:#{QUOTED_RGX}|#{NOTQ_RGX})\s*\)/
293
+ end
294
+
295
+ def initialize(matx)
296
+ super(matx)
297
+ # DONE: the regexp should only match on known field names...
298
+ # --> use qualified_regexp(attr_paths_rgx)
299
+ self.fn = matx[2]
300
+ get_assoc if @fn
301
+ # double quotes need to be unescaped
302
+ @val = matx[3] ? matx[3].gsub("''", "'") : matx[4]
303
+ @keyword = matx[1]
304
+ ve = Regexp.escape(@val)
305
+ # used for "filter with ruby"
306
+ case @keyword
307
+ when 'startswith'
308
+ @startend_rgx = Regexp.new("\A#{ve}")
309
+ when 'endswith'
310
+ @startend_rgx = Regexp.new("#{ve}\z")
311
+ else
312
+ raise FilterParseError
313
+ end
314
+ end
315
+
316
+ def whcl(dtcx)
317
+ seqfn = get_qualified_fn(dtcx)
318
+ case @keyword
319
+ when 'startswith'
320
+ Sequel.like(seqfn, "#{@val}%")
321
+ when 'endswith'
322
+ Sequel.like(seqfn, "%#{@val}")
323
+ else
324
+ raise FilterParseError
325
+ end
326
+ end
327
+
328
+ def gen_sql(dtcx)
329
+ sql_ = ''
330
+ dt = dtcx.respond_to?(:dataset) ? dtcx.dataset : dtcx
331
+ dt.literal_append(sql_, whcl(dt))
332
+ # puts "in gen_sql : sql_ == " , sql_
333
+ sql_
334
+ end
335
+
336
+ def apply_to_dataset(dtcx)
337
+ dtcx.where(whcl(dtcx))
338
+ end
339
+
340
+ def apply_to_object(inp)
341
+ get_value(inp) =~ @startend_rgx
342
+ end
343
+ end
344
+
345
+ FILTER_CLASSES = [EqualFilter,
346
+ SubstringOfFilterSig1,
347
+ SubstringOfFilterSig2,
348
+ StartOrEndsWithFilter].freeze
349
+ def Filter.new_by_simple_full_match(filterstr)
350
+ new_filt_obj = nil
351
+ OData::FILTER_CLASSES.find do |fklas|
352
+ new_filt_obj = fklas.new_full_match(filterstr)
353
+ end
354
+ new_filt_obj
355
+ end
356
+
357
+ # handles some combinations
358
+ class ComplexFilter < Filter
359
+ # list of active Subfilter classes. The ordering is important
360
+ SUBFILTERS = [EqualFilter,
361
+ SubstringOfFilterSig1,
362
+ StartOrEndsWithFilter].freeze
363
+ # for counting number of AND OR's
364
+ ANDORRGX = /(AND|OR\s+)/i.freeze
365
+
366
+ # for detecting consecutive AND OR
367
+ ANDORERRRGX = /(AND|OR)\s+[\(\)]*(AND|OR)/i.freeze
368
+
369
+ def initialize(filterstr, dtset)
370
+ super(nil)
371
+ @filterstr = filterstr.dup
372
+
373
+ @attrib_paths_url_regexp = if dtset.is_a? Sequel::Model::ClassMethods
374
+ dtset.attrib_paths_url_regexp.dup
375
+ else
376
+ dtset.model.attrib_paths_url_regexp.dup
377
+ end
378
+ @active_subfs = []
379
+ @osql = filterstr.dup
380
+ @dt = dtset
381
+
382
+ md = nil
383
+ @osql.with_mask_quoted_substrings! do |s|
384
+ unless (@and_or_err = ANDORERRRGX.match(s))
385
+ md = ANDORRGX.match(s)
386
+
387
+ @expected_count = md.nil? ? 1 : md.size
388
+ SUBFILTERS.each { |klass| init_subfilter(klass) }
389
+ end
390
+ end
391
+ end
392
+
393
+ def parse_error?
394
+ @and_or_err || (@active_subfs.size != @expected_count)
395
+ end
396
+
397
+ def init_subfilter(subfiltclass)
398
+ rgx = subfiltclass.qualified_regexp(@attrib_paths_url_regexp)
399
+ @osql.gsub!(rgx) do |_mx|
400
+ subf = subfiltclass.new(Regexp.last_match)
401
+ @active_subfs << subf
402
+ @assocs.add(subf.assoc.to_sym) if subf.assoc
403
+ subf.gen_sql(@dt)
404
+ end
405
+ end
406
+
407
+ def apply_to_dataset(dtcx)
408
+ # new Sequel 5.6 requires -- lit --
409
+ dtcx.where(Sequel.lit(@osql))
410
+ end
411
+ end
412
+
413
+ # Handle complex filter expression with Ruby
414
+ class ComplexFilterByRuby < Filter
415
+ def initialize(filterstr)
416
+ @subf = []
417
+ @f = nil
418
+ @rbcode = filterstr.dup
419
+ @rbcode.with_mask_quoted_substrings! do |_s|
420
+ SUBFILTERS.each { |klass| init_subfilter(klass) }
421
+ downcase_and_or
422
+ end
423
+ end
424
+
425
+ # downcase AND OR in @rbcode otherwise it is seen as a Ruby constant
426
+ # instead as a ruby langu keyword
427
+ def downcase_and_or
428
+ @rbcode.gsub!(/(OR|Or|AND|And)/) do |_mx|
429
+ Regexp.last_match[1].downcase
430
+ end
431
+ end
432
+
433
+ def init_subfilter(subfiltclass)
434
+ @rbcode.gsub!(subfiltclass.regexp) do |_mx|
435
+ @f = subfiltclass.new(Regexp.last_match)
436
+ @subf << @f
437
+ "@subf[#{@subf.size - 1}].apply_to_object(@o)"
438
+ end
439
+ end
440
+
441
+ def apply_to_object(inp)
442
+ @o = inp
443
+ b = binding
444
+ eval(@rbcode, b)
445
+ end
446
+ end
447
+ end
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'pp'
4
+ require 'odata/error.rb'
5
+
6
+ # Ordering with ruby expression
7
+ module OrderWithRuby
8
+ # this module requires the @fn attribute to exist where it is used
9
+ def fn=(fnam)
10
+ @fn = fnam
11
+ @fn_tab = fnam.split('/').map(&:to_sym)
12
+ end
13
+ end
14
+
15
+ # all ordering related classes in our OData module
16
+ module OData
17
+ # base class for ordering
18
+ class Order
19
+ attr_reader :assoc
20
+ attr_reader :assocs
21
+ attr_reader :oarg
22
+ def initialize(ostr, _dt)
23
+ ostr.strip!
24
+ @orderp = ostr
25
+ @assocs = Set.new
26
+ build_oarg if @orderp
27
+ end
28
+
29
+ class << self
30
+ attr_reader :regexp
31
+ end
32
+
33
+ # input : the filter string
34
+ # returns a filter object that should have a apply_to(cx) method
35
+ def self.new_by_parse(orderstr, dtset = nil)
36
+ Order.new_full_match_complexpr(orderstr, dtset)
37
+ end
38
+
39
+ # handle with Sequel
40
+ def self.new_full_match_complexpr(orderstr, dtset)
41
+ ComplexOrder.new(orderstr, dtset)
42
+ end
43
+
44
+ def apply_to_dataset(dtcx)
45
+ dtcx
46
+ end
47
+
48
+ def apply_associations(dtcx)
49
+ @assocs.each { |aj| dtcx = dtcx.association_join(aj) }
50
+ dtcx
51
+ end
52
+
53
+ def build_oarg
54
+ field, ord = @orderp.split(' ')
55
+ oargu = if field.include?('/')
56
+ @assoc, field = field.split('/')
57
+ @assoc = @assoc.to_sym
58
+ Sequel[@assoc][field.strip.to_sym]
59
+ else
60
+ Sequel[field.strip.to_sym]
61
+ end
62
+
63
+ @oarg = if ord == 'desc'
64
+ Sequel.desc(oargu)
65
+ else
66
+ Sequel.asc(oargu)
67
+ end
68
+ end
69
+ end
70
+
71
+ # complex ordering logic
72
+ class ComplexOrder < Order
73
+ def initialize(orderstr, dtset)
74
+ super
75
+ @dt = dtset
76
+ @olist = []
77
+ return unless orderstr
78
+
79
+ @olist = orderstr.split(',').map do |ostr|
80
+ oo = Order.new(ostr, dtset)
81
+ @assocs.add oo.assoc if oo.assoc
82
+ oo.oarg
83
+ end
84
+ end
85
+
86
+ def apply_to_dataset(dtcx)
87
+ @olist.each { |oarg| dtcx = dtcx.order(oarg) }
88
+ dtcx
89
+ end
90
+ end
91
+ end