safrano 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
- #!/usr/bin/env ruby
2
-
3
1
  require 'odata/error.rb'
4
2
 
3
+ require_relative 'filter/parse.rb'
4
+ require_relative 'filter/sequel.rb'
5
+
5
6
  # a few helper method
6
7
  class String
7
8
  MASK_RGX = /'((?:[^']|(?:\'{2}))*)'/.freeze
@@ -31,502 +32,30 @@ class String
31
32
  "'$#{cnt}'"
32
33
  end
33
34
  yield tmpstr
34
- # tmpstr2.gsub(UNMASK_RGX) do |_m|
35
- # k = Regexp.last_match(2).to_s
36
- # "'#{Regexp.last_match(1)}#{repl[k]}#{Regexp.last_match(3)}'"
37
- # end
38
35
  end
39
36
  end
40
37
 
41
38
  # filter base class and subclass in our OData namespace
42
39
  module OData
43
- # handles associations in filter arguments
44
- module FilterWithAssoc
45
- def get_assoc
46
- @assoc, @fn = @fn.split('/') if @fn.include?('/')
47
- end
48
-
49
- def get_qualified_fn(dtcx)
50
- seqtn = if dtcx.respond_to? :table_name
51
- @assoc ? @assoc.to_sym : dtcx.table_name
52
- else
53
- @assoc ? @assoc.to_sym : dtcx.model.table_name
54
- end
55
- Sequel[seqtn][@fn.to_sym]
56
- end
57
- end
58
-
59
- # base class for filtering
60
- class Filter
61
- attr_reader :assoc
62
- attr_reader :assocs
63
- QUO = /'((?:[^']|(?:\'{2}))*)'/.freeze
64
- NOTQ = /((?:[^'\s\)])*)/.freeze
65
-
66
- def initialize(matx)
67
- @matx = matx
68
- @assocs = Set.new
69
- end
70
-
71
- class << self
72
- attr_reader :regexp
73
- end
74
-
75
- # input : the filter string
76
- # returns a filter object that should have a apply_to(cx) method
77
- def self.new_by_parse(filterstr, dtset = nil)
78
- # handle complex flat expressions (flat = without relationships)
79
- # with Sequel
80
- ComplexFilter.new(filterstr, dtset)
81
- end
82
-
83
- # try to handle a simple expression like < fieldname EQ value >
84
- # or < substringof(a,b) >
85
- # NO parenthesis and no AND/OR handled here
86
- def self.new_full_match(filterstr)
87
- full_reg = Regexp.new(/\A#{regexp}\z/)
88
- # this needs to return nil, not false, or some testcase will fail
89
- return nil unless (matx = filterstr.match(full_reg))
90
-
91
- # actually it's self.new(matx)
92
- new(matx)
93
- end
94
-
95
- def apply_to_dataset(dtcx)
96
- dtcx
97
- end
98
-
99
- def apply_associations(dtcx)
100
- @assocs.each { |aj| dtcx = dtcx.association_join(aj) }
101
- dtcx
102
- end
103
- end
104
-
105
- # well...
106
- class EqualFilter < Filter
107
- include FilterWithAssoc
108
- EQL = '[eE][qQ]|[LlgGNn][eETt]'.freeze
109
-
110
- def self.qualified_regexp(paths_rx)
111
- /\s*(#{paths_rx})\s+(#{EQL})\s+(?:#{QUO}|#{NOTQ})\s*/
112
- end
113
-
114
- def initialize(matx)
115
- super(matx)
116
- @fn = matx[1]
117
- get_assoc if @fn
118
-
119
- @val = matx[3] ? matx[3].gsub("''", "'") : matx[4]
120
-
121
- @op = matx[2].downcase
122
- end
123
-
124
- # ugly hack... but working
125
- def gen_sql(dtcx)
126
- # handle ambiguous column names (joins) by qualifiying the names
127
- # seqfn = @assoc ? Sequel[@assoc.to_sym][@fn.to_sym] : @fn.to_sym
128
- # handle ambiguous column names (joins) by qualifiying the names
129
- # for the main table as well
130
- # TODO: find a better way to differentiate between the two types
131
- # of dtcx : Model or Dataset
132
-
133
- # DONE: same handling for order and substring and everywhere where
134
- # qualified fieldnames could be needed (uses attrib path regexp)
135
- x = apply_to_dataset(dtcx)
136
- # ugly ugly hack :-(
137
- y = x.unordered.sql.sub(x.unfiltered.unordered.sql + ' WHERE', '')
138
- y
139
- end
140
-
141
- def apply_op_to_dataset(dtcx, lefval, rightval)
142
- case @op
143
- when 'eq'
144
- dtcx.where(lefval => rightval)
145
- when 'ne'
146
- dtcx.exclude(lefval => rightval)
147
- when 'le'
148
- dtcx.where { lefval <= rightval }
149
- when 'ge'
150
- dtcx.where { lefval >= rightval }
151
- when 'lt'
152
- dtcx.where { lefval < rightval }
153
- when 'gt'
154
- dtcx.where { lefval > rightval }
155
- else
156
- raise OData::FilterParseError
157
- end
158
- end
159
-
160
- def apply_to_dataset(dtcx)
161
- seqfn = get_qualified_fn(dtcx)
162
- # using @val in Procs below does not work reliably. Using a local var
163
- # seems to work better
164
- argval = @val
165
-
166
- apply_op_to_dataset(dtcx, seqfn, argval)
167
- end
168
-
169
- def apply_to_object(inp)
170
- case @op
171
- when 'eq'
172
- get_value(inp) == @val
173
- when 'ne'
174
- get_value(inp) != @val
175
- else
176
- raise OData::FilterParseError
177
- end
178
- end
179
- end
180
-
181
- class Func2a_EqualFilter < EqualFilter
182
- FUNC = 'concat'.freeze
183
- attr_reader :funcname
184
-
185
- # like for concat(Name, 'xyz') EQ 'blablux'
186
- def self.qualified_regexp(pathsrx)
187
- /\s*(#{FUNC})\((#{pathsrx}),\s*(?:#{QUO})\)\s+(#{EQL})\s+(?:#{QUO})\s*/
188
- end
189
-
190
- def initialize(matx)
191
- # super(matx)
192
- @funcname = matx[1]
193
- @fn = matx[2]
194
- get_assoc if @fn
195
- @farg = matx[3].gsub("''", "'")
196
- @op = matx[4].downcase
197
-
198
- @val = matx[5].gsub("''", "'")
199
- end
200
-
201
- def apply_to_dataset(dtcx)
202
- seqfn = get_qualified_fn(dtcx)
203
- # using @val in Procs below does not work reliably. Using a local var
204
- # seems to work better
205
- argval = @val
206
- seqfunc = case @funcname
207
- when 'concat'
208
- Sequel.join([seqfn, @farg])
209
- else
210
- raise OData::FilterParseError
211
- end
212
- apply_op_to_dataset(dtcx, seqfunc, argval)
213
- end
214
- end
215
- class Func2b_EqualFilter < EqualFilter
216
- FUNC = 'concat'.freeze
217
- attr_reader :funcname
218
-
219
- # like for concat('xyz', Name) EQ 'blablux'
220
- def self.qualified_regexp(pathsrx)
221
- /\s*(#{FUNC})\((?:#{QUO}),\s*(#{pathsrx})\)\s+(#{EQL})\s+(?:#{QUO})\s*/
222
- end
223
-
224
- def initialize(matx)
225
- # super(matx)
226
- @funcname = matx[1]
227
- @fn = matx[3]
228
- get_assoc if @fn
229
- @farg = matx[2].gsub("''", "'")
230
- @op = matx[4].downcase
231
-
232
- @val = matx[5].gsub("''", "'")
233
- end
234
-
235
- def apply_to_dataset(dtcx)
236
- seqfn = get_qualified_fn(dtcx)
237
- # using @val in Procs below does not work reliably. Using a local var
238
- # seems to work better
239
- argval = @val
240
- seqfunc = case @funcname
241
- when 'concat'
242
- Sequel.join([@farg, seqfn])
243
- else
244
- raise OData::FilterParseError
245
- end
246
- apply_op_to_dataset(dtcx, seqfunc, argval)
247
- end
248
- end
249
-
250
- class Func2c_EqualFilter < EqualFilter
251
- FUNC = 'concat'.freeze
252
- attr_reader :funcname
253
-
254
- # like for concat(first_ame, last_name) EQ 'blablux'
255
- def self.qualified_regexp(pathsrx)
256
- /\s*(#{FUNC})\((#{pathsrx}),\s*(#{pathsrx})\)\s+(#{EQL})\s+(?:#{QUO})\s*/
257
- end
258
-
259
- def get_assoc
260
- @assoc, @fn = @fn.split('/') if @fn.include?('/')
261
- assoc1, @fn1 = @fn1.split('/') if @fn1.include?('/')
262
- return unless assoc1 != @assoc
263
-
264
- # TODO... handle this
265
- raise OData::ServerError
266
- end
267
-
268
- def get_qualified_fn1(dtcx)
269
- seqtn = if dtcx.respond_to? :table_name
270
- @assoc ? @assoc.to_sym : dtcx.table_name
271
- else
272
- @assoc ? @assoc.to_sym : dtcx.model.table_name
273
- end
274
- Sequel[seqtn][@fn1.to_sym]
275
- end
276
-
277
- def initialize(matx)
278
- # super(matx)
279
- @funcname = matx[1]
280
- @fn = matx[2]
281
- @fn1 = matx[3]
282
- get_assoc if @fn
283
-
284
- @op = matx[4].downcase
285
-
286
- @val = matx[5].gsub("''", "'")
287
- end
288
-
289
- def apply_to_dataset(dtcx)
290
- seqfn = get_qualified_fn(dtcx)
291
- seqfn1 = get_qualified_fn1(dtcx)
292
- # using @val in Procs below does not work reliably. Using a local var
293
- # seems to work better
294
- argval = @val
295
- seqfunc = case @funcname
296
- when 'concat'
297
- Sequel.join([seqfn, seqfn1])
298
- else
299
- raise OData::FilterParseError
300
- end
301
- apply_op_to_dataset(dtcx, seqfunc, argval)
302
- end
303
- end
304
-
305
- # equality expressions with functions having 1 parameter
306
- # like for length(name) eq 2
307
- class FuncEqualFilter < EqualFilter
308
- FUNC = 'length|trim|tolower|toupper'.freeze
309
-
310
- attr_reader :funcname
311
-
312
- def self.qualified_regexp(pathsrx)
313
- /\s*(#{FUNC})\((#{pathsrx})\)\s+(#{EQL})\s+(?:#{QUO}|#{NOTQ})\s*/
314
- end
315
-
316
- def initialize(matx)
317
- super(matx)
318
- @funcname = matx[1]
319
- @fn = matx[2]
320
- get_assoc if @fn
321
-
322
- @op = matx[3].downcase
323
-
324
- @val = matx[4] ? matx[4].gsub("''", "'") : matx[5]
325
- end
326
-
327
- def apply_to_dataset(dtcx)
328
- seqfn = get_qualified_fn(dtcx)
329
- # using @val in Procs below does not work reliably. Using a local var
330
- # seems to work better
331
- argval = @val
332
- seqfunc = case @funcname
333
- when 'length'
334
- argval = Sequel.lit(@val)
335
- Sequel.char_length(seqfn)
336
- when 'trim'
337
- Sequel.trim(seqfn)
338
- when 'toupper'
339
- Sequel.function(:upper, seqfn)
340
- when 'tolower'
341
- Sequel.function(:lower, seqfn)
342
- else
343
- raise OData::FilterParseError
344
- end
345
-
346
- apply_op_to_dataset(dtcx, seqfunc, argval)
347
- end
348
- end
349
-
350
- # keyword based
351
- class FilterWithKeyword < Filter
352
- attr_reader :keyword
353
- end
354
-
355
- # for substringof('value', field)
356
- class SubstringOfFilterSig1 < FilterWithKeyword
357
- include FilterWithAssoc
358
-
359
- def self.qualified_regexp(paths_rx)
360
- # Note: QUOTED_RGX captures the string content without
361
- # start and end quote (no need to un-quote)
362
- # NOTQ_RGX captures an not-quoted value argument
363
- # (like for numbers)
364
- /(substringof)\((?:#{QUO}|#{NOTQ}),\s*(#{paths_rx})\)/
365
- end
366
-
367
- def initialize(matx)
368
- super(matx)
369
-
370
- @fn = matx[4]
371
- get_assoc if @fn
372
- # @val = matx[2].strip_single_quote
373
- @val = matx[2] ? matx[2].gsub("''", "'") : matx[3]
374
- @keyword = matx[1]
375
- end
376
-
377
- def whcl(dtcx)
378
- # handle ambiguous column names (joins) by qualifiying the names
379
- # seqfn = @assoc ? Sequel[@assoc.to_sym][@fn.to_sym] : @fn.to_sym
380
- seqfn = get_qualified_fn(dtcx)
381
- Sequel.like(seqfn, "%#{@val}%")
382
- end
383
-
384
- def gen_sql(dtcx)
385
- sql_ = ''
386
- dt = dtcx.respond_to?(:dataset) ? dtcx.dataset : dtcx
387
- dt.literal_append(sql_, whcl(dt))
388
- sql_
389
- end
390
-
391
- def apply_to_dataset(dtcx)
392
- dtcx.where(whcl(dtcx))
393
- end
394
- end
395
- # for substringof(field,'value')
396
- class SubstringOfFilterSig2 < FilterWithKeyword
397
- include FilterWithAssoc
398
-
399
- def initialize(matx)
400
- super(matx)
401
- @fn = matx[2]
402
- get_assoc if @fn
403
- @val = matx[3]
404
- @keyword = matx[1]
405
-
406
- raise FilterParseError
407
- end
408
-
409
- def apply_to_dataset(dtcx)
410
- dtcx
411
- end
412
- end
413
-
414
- # Filter by start/end with
415
- class StartOrEndsWithFilter < FilterWithKeyword
416
- include FilterWithAssoc
417
- # DONE: better handle quotes vs not-quotes. Unblanced quotes are now handled
418
- # example : startswith(year, 190')
419
-
420
- def self.qualified_regexp(paths)
421
- /(endswith|startswith)\((#{paths}),\s*(?:#{QUO}|#{NOTQ})\s*\)/
422
- end
423
-
424
- def initialize(matx)
425
- super(matx)
426
- # DONE: the regexp should only match on known field names...
427
- # --> use qualified_regexp(attr_paths_rgx)
428
- @fn = matx[2]
429
- get_assoc if @fn
430
- # double quotes need to be unescaped
431
- @val = matx[3] ? matx[3].gsub("''", "'") : matx[4]
432
- @keyword = matx[1]
433
- end
434
-
435
- def whcl(dtcx)
436
- seqfn = get_qualified_fn(dtcx)
437
- case @keyword
438
- when 'startswith'
439
- Sequel.like(seqfn, "#{@val}%")
440
- when 'endswith'
441
- Sequel.like(seqfn, "%#{@val}")
442
- else
443
- raise FilterParseError
444
- end
445
- end
446
-
447
- def gen_sql(dtcx)
448
- sql_ = ''
449
- dt = dtcx.respond_to?(:dataset) ? dtcx.dataset : dtcx
450
- dt.literal_append(sql_, whcl(dt))
451
- # puts "in gen_sql : sql_ == " , sql_
452
- sql_
40
+ # should handle everything by parsing
41
+ class FilterByParse
42
+ def initialize(filterstr, jh)
43
+ @filterstr = filterstr.dup
44
+ @ast = OData::Filter::Parser.new(@filterstr).build
45
+ @jh = jh
453
46
  end
454
47
 
455
48
  def apply_to_dataset(dtcx)
456
- dtcx.where(whcl(dtcx))
49
+ filtexpr = @ast.sequel_expr(@jh)
50
+ dtcx = @jh.dataset(dtcx).where(filtexpr).select_all(@jh.start_model.table_name)
457
51
  end
458
- end
459
52
 
460
- FILTER_CLASSES = [EqualFilter,
461
- SubstringOfFilterSig1,
462
- SubstringOfFilterSig2,
463
- StartOrEndsWithFilter].freeze
464
- def Filter.new_by_simple_full_match(filterstr)
465
- new_filt_obj = nil
466
- OData::FILTER_CLASSES.find do |fklas|
467
- new_filt_obj = fklas.new_full_match(filterstr)
468
- end
469
- new_filt_obj
470
- end
471
-
472
- # handles some combinations
473
- class ComplexFilter < Filter
474
- # list of active Subfilter classes. The ordering is important
475
- SUBFILTERS = [EqualFilter,
476
- FuncEqualFilter,
477
- Func2a_EqualFilter,
478
- Func2b_EqualFilter,
479
- Func2c_EqualFilter,
480
- SubstringOfFilterSig1,
481
- StartOrEndsWithFilter].freeze
482
- # for counting number of AND OR's
483
- ANDORRGX = /(AND|OR\s+)/i.freeze
484
-
485
- # for detecting consecutive AND OR
486
- ANDORERRRGX = /(AND|OR)\s+[\(\)]*(AND|OR)/i.freeze
487
-
488
- def initialize(filterstr, dtset)
489
- super(nil)
490
- @filterstr = filterstr.dup
491
-
492
- @attrib_paths_url_regexp = if dtset.is_a? Sequel::Model::ClassMethods
493
- dtset.attrib_paths_url_regexp.dup
494
- else
495
- dtset.model.attrib_paths_url_regexp.dup
496
- end
497
-
498
- @active_subfs = []
499
- @osql = filterstr.dup
500
- @dt = dtset
501
-
502
- md = nil
503
- @osql.with_mask_quoted_substrings! do |s|
504
- unless (@and_or_err = ANDORERRRGX.match(s))
505
- md = ANDORRGX.match(s)
506
-
507
- @expected_count = md.nil? ? 1 : md.size
508
- SUBFILTERS.each { |klass| init_subfilter(klass) }
509
- end
510
- end
53
+ def sequel_expr
54
+ @ast.sequel_expr(@jh)
511
55
  end
512
56
 
513
57
  def parse_error?
514
- @and_or_err || (@active_subfs.size != @expected_count)
515
- end
516
-
517
- def init_subfilter(subfiltclass)
518
- rgx = subfiltclass.qualified_regexp(@attrib_paths_url_regexp)
519
- @osql.gsub!(rgx) do |_mx|
520
- subf = subfiltclass.new(Regexp.last_match)
521
- @active_subfs << subf
522
- @assocs.add(subf.assoc.to_sym) if subf.assoc
523
- subf.gen_sql(@dt)
524
- end
525
- end
526
-
527
- def apply_to_dataset(dtcx)
528
- # new Sequel 5.6 requires -- lit --
529
- dtcx.where(Sequel.lit(@osql))
58
+ @ast.kind_of? StandardError
530
59
  end
531
60
  end
532
61
  end