safrano 0.0.1 → 0.0.2

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.
@@ -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