praxis 2.0.pre.13 → 2.0.pre.14

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4be08481cbad0604fdb8e45dfc66aec895478a0b7578544b773af7fa6f3863f
4
- data.tar.gz: 122625c252f5d9a2166e8958408bedf7ac5aee13500488e44c453ddeff3fbcc8
3
+ metadata.gz: 6af11de4fdf970e848d638ed6fd1f47db86dcd27562bca90e50805398a82b346
4
+ data.tar.gz: dd4b664b1b8afde291e0402eeb0bb57178214ac1d8d6a4dc691f532fff7ae29f
5
5
  SHA512:
6
- metadata.gz: 6472e4ef1f9ad601ed5c13774a336b93d52356a3a0bccb3a3707953658918a79457d314ceca1ce676a15f51c10bcebb4556388c95ba3aeca2cbec40625b13734
7
- data.tar.gz: d8a4bad9469579530974d3c61d61fd7441b4e9d48d2ecb90ac81d4951cda0293428f750011e406e755103abb162b4fb1c60b87a584fe77da74c68dafaa997ecd
6
+ metadata.gz: 525c64c37e1cfe27fe74c1ddbd02d3c6e45860acd0f8a79a5d9a0832274368c728dacc8096b3b37f63c302fe265161793320a64dcf44a84d99ee5074d9de9da2
7
+ data.tar.gz: 79a70e175f17a14d98beddd66add981c03a8c720dd45855221b5b3223cb021d056381a5fd87880e3cbe2886755445182cf6cad3427de9f38b44220a26b23955d
data/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## next
4
4
 
5
+ ## 2.0.pre.14
6
+
7
+ * More encoding/decoding robustness for filters.
8
+ * Specs for how to encode filters are now properly defined by:
9
+ * The "value" of the filters query string needs to be URI encoded (like any other query string value). This encoding is subject to the normal rules, and therefore "could" leave some of the URI unreserved characters (i.e., 'markers') unencoded depending on the client (Section 2.2 of https://tools.ietf.org/html/rfc2396).
10
+ * The "values" for any of the conditions in the contents of the filters, however, will need to be properly "escaped" as well (prior to URL-encoding the whole syntax string itself like described above). This means that any match value needs to ensure that it has (at least) "(",")","|","&" and "," escaped as they are reserved characters for the filter expression syntax. For example, if I want to search for a name with value "Rocket&(Pants)", I need to first compose the syntax by: "name=<escaped Rocket&(Pants)>, which is "name=Rocket%26%28Pants%29" and then, just URI encode that query string value for the filters parameter in the URL like any other. For example: "filters=name%3DRocket%2526%2528Pants%2529"
11
+ * When using a multi-match (csv-separated) list of values, you need to escape each of the values as well, leaving the 'comma' unescape, as that's part of the syntax. Then uri-encode it all for the filters query string parameter value like above.
12
+ * Now, one can properly differentiate between fuzzy query prefix/postfix, and the literal data to search for (which can be or include '*'). Report that multi-matches (i.e., csv separated values for a single field, which translate into "IN" clauses) is not allowed if fuzzy matches are received (need to use multiple OR clauses for it).
13
+
5
14
  ## 2.0.pre.13
6
15
 
7
16
  * Fix filters parser regression, which would incorrectly decode url-encoded values
@@ -1,2 +1,15 @@
1
1
  require 'praxis/extensions/attribute_filtering/filtering_params'
2
- require 'praxis/extensions/attribute_filtering/filter_tree_node'
2
+ require 'praxis/extensions/attribute_filtering/filter_tree_node'
3
+ module Praxis
4
+ module Extensions
5
+ module AttributeFiltering
6
+ class MultiMatchWithFuzzyNotAllowedByAdapter < StandardError
7
+ def initialize
8
+ msg = 'Matching multiple, comma-separated values with fuzzy matches for a single field is not allowed by this DB adapter'\
9
+ 'Please use multiple OR clauses instead.'
10
+ super(msg)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -72,7 +72,8 @@ module Praxis
72
72
  column_prefix: column_prefix,
73
73
  column_object: colo,
74
74
  op: condition[:op],
75
- value: condition[:value]
75
+ value: condition[:value],
76
+ fuzzy: condition[:fuzzy]
76
77
  )
77
78
  end
78
79
 
@@ -192,8 +193,8 @@ module Praxis
192
193
  {associations_hash: h, conditions: conditions}
193
194
  end
194
195
 
195
- def self.add_clause(query:, column_prefix:, column_object:, op:, value:)
196
- likeval = get_like_value(value)
196
+ def self.add_clause(query:, column_prefix:, column_object:, op:, value:,fuzzy:)
197
+ likeval = get_like_value(value,fuzzy)
197
198
  case op
198
199
  when '!' # name! means => name IS NOT NULL (and the incoming value is nil)
199
200
  op = '!='
@@ -265,12 +266,22 @@ module Praxis
265
266
  end
266
267
 
267
268
  # Returns nil if the value was not a fuzzzy pattern
268
- def self.get_like_value(value)
269
- if value.is_a?(String) && (value[-1] == '*' || value[0] == '*')
270
- likeval = value.dup
271
- likeval[-1] = '%' if value[-1] == '*'
272
- likeval[0] = '%' if value[0] == '*'
273
- likeval
269
+ def self.get_like_value(value,fuzzy)
270
+ is_fuzzy = fuzzy.is_a?(Array) ? !fuzzy.compact.empty? : fuzzy
271
+ if is_fuzzy
272
+ unless value.is_a?(String)
273
+ raise MultiMatchWithFuzzyNotAllowedByAdapter.new
274
+ end
275
+ case fuzzy
276
+ when :start_end
277
+ '%'+value+'%'
278
+ when :start
279
+ '%'+value
280
+ when :end
281
+ value+'%'
282
+ end
283
+ else
284
+ nil
274
285
  end
275
286
  end
276
287
 
@@ -15,7 +15,7 @@ module Praxis
15
15
  if components.empty?
16
16
  return
17
17
  elsif components.size == 1
18
- @conditions << hash.slice(:name, :op, :value, :node_object)
18
+ @conditions << hash.slice(:name, :op, :value, :fuzzy, :node_object)
19
19
  else
20
20
  children_data[components.first] ||= []
21
21
  children_data[components.first] << hash
@@ -182,7 +182,7 @@ module Praxis
182
182
  else
183
183
  spec[:values]
184
184
  end
185
- accum.push(name: attr_name, op: spec[:op], value: coerced , node_object: spec[:node_object])
185
+ accum.push(name: attr_name, op: spec[:op], value: coerced , fuzzy: spec[:fuzzies], node_object: spec[:node_object])
186
186
  end
187
187
  new(accum)
188
188
  end
@@ -225,7 +225,7 @@ module Praxis
225
225
  value = item[:value]
226
226
  unless value.empty?
227
227
  fuzzy_match = attr_filters[:fuzzy_match]
228
- if (value[-1] == '*' || value[0] == '*') && !fuzzy_match
228
+ if item[:fuzzy] && !item[:fuzzy].empty? && !fuzzy_match
229
229
  errors << "Fuzzy matching for #{attr_name} is not allowed (yet '*' was found in the value)"
230
230
  end
231
231
  end
@@ -15,41 +15,81 @@ module Praxis
15
15
  # Example: [{:name=>"multi"@0, :op=>"="@5}, {:value=>"1"@6}, {:value=>"2"@8}]
16
16
  def initialize(triad:, parent_group:)
17
17
  @parent_group = parent_group
18
-
19
18
  if triad.is_a? Array # several values coming in
20
19
  spec, *values = triad
21
20
  @name = spec[:name].to_sym
22
21
  @op = spec[:op].to_s
23
22
 
24
- @values = if values.empty?
25
- ""
23
+ if values.empty?
24
+ @values = ""
25
+ @fuzzies = nil
26
26
  elsif values.size == 1
27
- CGI.unescape(values.first[:value].to_s)
27
+ raw_val = values.first[:value].to_s
28
+ @values, @fuzzies = _compute_fuzzy(values.first[:value].to_s)
28
29
  else
29
- values.map{|e| CGI.unescape(e[:value].to_s)}
30
+ @values = []
31
+ @fuzzies = []
32
+ results = values.each do|e|
33
+ val, fuz = _compute_fuzzy(e[:value].to_s)
34
+ @values.push val
35
+ @fuzzies.push fuz
36
+ end
30
37
  end
31
38
  else # No values for the operand
32
39
  @name = triad[:name].to_sym
33
40
  @op = triad[:op].to_s
34
41
  if ['!','!!'].include?(@op)
35
- @values = nil
42
+ @values, @fuzzies = [nil, nil]
36
43
  else
37
44
  # Value operand without value? => convert it to empty string
38
45
  raise "Interesting, didn't know this could happen. Oops!" if triad[:value].is_a?(Array) && !triad[:value].empty?
39
- @values = (triad[:value] == []) ? '' : CGI.unescape(triad[:value].to_s) # TODO: could this be an array (or it always comes the other if)
46
+ if triad[:value] == []
47
+ @values, @fuzzies = ['', nil]
48
+ else
49
+ @values, @fuzzies = _compute_fuzzy(triad[:value].to_s)
50
+ end
40
51
  end
41
52
  end
42
53
  end
43
-
54
+ # Takes a raw val, and spits out the output val (unescaped), and the fuzzy definition
55
+ def _compute_fuzzy(raw_val)
56
+ starting = raw_val[0] == '*'
57
+ ending = raw_val[-1] == '*'
58
+ newval, fuzzy = if starting && ending
59
+ [raw_val[1..-2], :start_end]
60
+ elsif starting
61
+ [raw_val[1..-1], :start]
62
+ elsif ending
63
+ [raw_val[0..-2], :end]
64
+ else
65
+ [raw_val,nil]
66
+ end
67
+ newval = CGI.unescape(newval) if newval
68
+ [newval,fuzzy]
69
+ end
44
70
  def flattened_conditions
45
- [{name: @name, op: @op, values: @values, node_object: self}]
71
+ [{name: @name, op: @op, values: @values, fuzzies: @fuzzies, node_object: self}]
46
72
  end
47
73
 
74
+ # Dumps the value, marking where the fuzzy might be, and removing the * to differentiate from literals
75
+ def _dump_value(val,fuzzy)
76
+ case fuzzy
77
+ when nil
78
+ val
79
+ when :start_end
80
+ '{*}' + val + '{*}'
81
+ when :start
82
+ '{*}' + val
83
+ when :end
84
+ val +'{*}'
85
+ end
86
+ end
48
87
  def dump
49
88
  vals = if values.is_a? Array
50
- "[#{values.join(',')}]" # Purposedly enclose in brackets to make sure we differentiate
89
+ dumped = values.map.with_index{|val,i| _dump_value(val, @fuzzies[i])}
90
+ "[#{dumped.join(',')}]" # Purposedly enclose in brackets to make sure we differentiate
51
91
  else
52
- (values == '') ? "\"#{values}\"" : values # Dump the empty string explicitly with quotes if we've converted no value to empty string
92
+ (values == '') ? '""' : _dump_value(values,@fuzzies) # Dump the empty string explicitly with quotes if we've converted no value to empty string
53
93
  end
54
94
  "#{name}#{op}#{vals}"
55
95
  end
@@ -134,7 +174,7 @@ module Praxis
134
174
  end
135
175
 
136
176
  rule(:name) { match('[a-zA-Z0-9_\.]').repeat(1) } # TODO: are these the only characters that we allow for names?
137
- rule(:chars) { match('[^&|),]').repeat(0).as(:value) }
177
+ rule(:chars) { match('[^&|(),]').repeat(0).as(:value) }
138
178
  rule(:value) { chars >> (comma >> chars ).repeat }
139
179
 
140
180
  rule(:triad) {
@@ -1,3 +1,3 @@
1
1
  module Praxis
2
- VERSION = '2.0.pre.13'
2
+ VERSION = '2.0.pre.14'
3
3
  end
@@ -71,7 +71,7 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
71
71
  end
72
72
  end
73
73
  end
74
- context 'that maps to a different name' do
74
+ context 'that maps to a different name' do
75
75
  let(:filters_string) { 'name=Book1'}
76
76
  it_behaves_like 'subject_equivalent_to', ActiveBook.where(simple_name: 'Book1')
77
77
  end
@@ -79,6 +79,23 @@ describe Praxis::Extensions::AttributeFiltering::ActiveRecordFilterQueryBuilder
79
79
  let(:filters_string) { 'fake_nested.name=Book1'}
80
80
  it_behaves_like 'subject_equivalent_to', ActiveBook.where(simple_name: 'Book1')
81
81
  end
82
+ context 'passing multiple values' do
83
+ context 'without fuzzy matching' do
84
+ let(:filters_string) { 'category_uuid=deadbeef1,deadbeef2' }
85
+ it_behaves_like 'subject_equivalent_to', ActiveBook.where(category_uuid: ['deadbeef1','deadbeef2'])
86
+ end
87
+ context 'with fuzzy matching' do
88
+ let(:filters_string) { 'category_uuid=*deadbeef1,deadbeef2*' }
89
+ it 'is not supported' do
90
+ expect{
91
+ subject
92
+ }.to raise_error(
93
+ Praxis::Extensions::AttributeFiltering::MultiMatchWithFuzzyNotAllowedByAdapter,
94
+ /Please use multiple OR clauses instead/
95
+ )
96
+ end
97
+ end
98
+ end
82
99
  end
83
100
 
84
101
  context 'by a field or a related model' do
@@ -29,7 +29,14 @@ describe Praxis::Extensions::AttributeFiltering::FilteringParams do
29
29
  { name: :two, op: '>', value: 'normal'},
30
30
  ]
31
31
  expect(described_class.load(str).parsed_array.map{|i| i.slice(:name,:op,:value)}).to eq(parsed)
32
- end
32
+ end
33
+ it 'does not handle badly escaped values that contain reserved chars ()|&,' do
34
+ badly_escaped = 'val('
35
+ str = "one=#{badly_escaped}&(two>normal|three!)"
36
+ expect{
37
+ described_class.load(str)
38
+ }.to raise_error(Parslet::ParseFailed)
39
+ end
33
40
  end
34
41
  context 'parses for operator' do
35
42
  described_class::VALUE_OPERATORS.each do |op|
@@ -1,6 +1,5 @@
1
1
  require 'praxis/extensions/attribute_filtering/filters_parser'
2
2
 
3
-
4
3
  describe Praxis::Extensions::AttributeFiltering::FilteringParams::Condition do
5
4
  end
6
5
 
@@ -121,17 +120,26 @@ describe Praxis::Extensions::AttributeFiltering::FilteringParams::Parser do
121
120
  context 'supports everything (except &|(),) for values (even without encoding..not allowed, but just to ensure the parser does not bomb)' do
122
121
  it_behaves_like 'round-trip-properly', {
123
122
  'v=1123' => 'v=1123',
124
- 'v=*foo*' => 'v=*foo*',
125
- 'v=*^%$#@!foo' => 'v=*^%$#@!foo',
123
+ 'v=*foo*' => 'v={*}foo{*}',
124
+ 'v=*^%$#@!foo' => 'v={*}^%$#@!foo',
126
125
  'v=_-=\{}"?:><' => 'v=_-=\{}"?:><',
127
126
  'v=_-=\{}"?:><,another_value!' => 'v=[_-=\{}"?:><,another_value!]',
128
127
  }
129
128
  end
129
+ context 'properly detects and handles fuzzy matching encoded as {*} in the dump' do
130
+ it_behaves_like 'round-trip-properly', {
131
+ 'v=*foo' => 'v={*}foo',
132
+ 'v=*foo*' => 'v={*}foo{*}',
133
+ 'v=foo*' => 'v=foo{*}',
134
+ 'v=*start,end*,*both*' => 'v=[{*}start,end{*},{*}both{*}]',
135
+ "v=*#{CGI.escape('***')},#{CGI.escape('*')}" => 'v=[{*}***,*]', # Simple exact match on 2nd
136
+ }
137
+ end
130
138
  context 'properly handles url-encoded values' do
131
139
  it_behaves_like 'round-trip-properly', {
132
140
  "v=#{CGI.escape('1123')}" => 'v=1123',
133
- "v=#{CGI.escape('*foo*')}" => 'v=*foo*',
134
- "v=#{CGI.escape('*^%$#@!foo')}" => 'v=*^%$#@!foo',
141
+ "v=*#{CGI.escape('foo')}*" => 'v={*}foo{*}',
142
+ "v=*#{CGI.escape('^%$#@!foo')}" => 'v={*}^%$#@!foo',
135
143
  "v=#{CGI.escape('~!@#$%^&*()_+-={}|[]\:";\'<>?,./`')}" => 'v=~!@#$%^&*()_+-={}|[]\:";\'<>?,./`',
136
144
  "v=#{CGI.escape('_-+=\{}"?:><')},#{CGI.escape('another_value!')}" => 'v=[_-+=\{}"?:><,another_value!]',
137
145
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: praxis
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.pre.13
4
+ version: 2.0.pre.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josep M. Blanquer
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2021-02-24 00:00:00.000000000 Z
12
+ date: 2021-02-26 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rack