praxis 2.0.pre.11 → 2.0.pre.12

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: 7bbd374311046cf8d12c68d564382de6cbfd0c1032c45bbc8c1ac00d7a02e68a
4
- data.tar.gz: e33676f45266facdbcae595c2c05ca94ae8dfa34134aa9e4113e5a5d9d729052
3
+ metadata.gz: cf4b54b686373509e0920a93d482e29f4c198491f88d382b6ff429e5128d6574
4
+ data.tar.gz: '08b2d7342069e8584676e03c828e144b99eb9282b193113b987d8060cb143cfd'
5
5
  SHA512:
6
- metadata.gz: 5db86e95bd0b723560036435ded99b893fd92bd55f20dc4b49e326d96e7c9422549124ba986eb3248ff746d6bf928a60e6677b854c8761a34c026c7430a578bb
7
- data.tar.gz: 39677f252617f79d3d054fd2286562d3355310d8161096c7c73294d2b242cee4ebdf749b462d7f1c5261fc487e80ba473dcd258dcb77a510c47db7e698275cad
6
+ metadata.gz: ef7cd7fe985d4af76cf7ec6459e58990b8dbc6f3c4fac5c26ce04d56e23a3d743a0f4bb776f4d5c913224e031567e9993ad52e611e4258ee853cdf86b3099255
7
+ data.tar.gz: f4fe45f97c9bc26aa513a24ee358b04dc114af0e7f4849071f699c0c22df460253452dd54e9bcb510fc4f977aa9104774f70c882f46b8aa937404e79814d6f3d
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 2.6.3
1
+ 2.7.1
data/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## next
4
4
 
5
+ ## 2.0.pre.12
6
+
7
+ * Rebuilt API filters to support a much richer syntax. One can now use ANDs and ORs (with ANDs having order precedence), as well as group them with parenthesis. The same individual filter operands are supported. For example: 'email=*@gmail.com&(friends.first_name=Joe*,Patty|friends.last_name=Smith)
8
+
5
9
  ## 2.0.pre.11
6
10
 
7
11
  - Remove MapperPlugin's `set_selectors` (made `selector_generator` lazy instead), and ensure it includes the rendering extensions to the Controllers. Less things to configure if you opt into the Mapper way.
@@ -23,14 +23,16 @@ module Praxis
23
23
  end
24
24
 
25
25
  class ActiveRecordFilterQueryBuilder
26
- attr_reader :query, :model, :filters_map
26
+ attr_reader :model, :filters_map
27
27
 
28
28
  # Base query to build upon
29
29
  def initialize(query: , model:, filters_map:, debug: false)
30
- @query = query
30
+ # Note: Do not make the initial_query an attr reader to make sure we don't count/leak on modifying it. Easier to mostly use class methods
31
+ @initial_query = query
31
32
  @model = model
32
33
  @filters_map = filters_map
33
34
  @logger = debug ? Logger.new(STDOUT) : nil
35
+ @active_record_version_maj = ActiveRecord.gem_version.segments[0]
34
36
  end
35
37
 
36
38
  def debug_query(msg, query)
@@ -40,26 +42,97 @@ module Praxis
40
42
  def generate(filters)
41
43
  # Resolve the names and values first, based on filters_map
42
44
  root_node = _convert_to_treenode(filters)
43
- craft_filter_query(root_node, for_model: @model)
44
- debug_query("SQL due to filters: ", @query.all)
45
- @query
45
+ crafted = craft_filter_query(root_node, for_model: @model)
46
+ debug_query("SQL due to filters: ", crafted.all)
47
+ crafted
46
48
  end
47
49
 
48
50
  def craft_filter_query(nodetree, for_model:)
49
51
  result = _compute_joins_and_conditions_data(nodetree, model: for_model)
50
- @query = query.joins(result[:associations_hash]) unless result[:associations_hash].empty?
52
+ return @initial_query if result[:conditions].empty?
51
53
 
52
- result[:conditions].each do |condition|
53
- filter_name = condition[:name]
54
- filter_value = condition[:value]
54
+
55
+ # Find the root group (usually an AND group) but can be an OR group, or nil if there's only 1 condition
56
+ root_parent_group = result[:conditions].first[:node_object].parent_group || result[:conditions].first[:node_object]
57
+ while root_parent_group.parent_group != nil
58
+ root_parent_group = root_parent_group.parent_group
59
+ end
60
+
61
+ # Process the joins
62
+ query_with_joins = result[:associations_hash].empty? ? @initial_query : @initial_query.joins(result[:associations_hash])
63
+
64
+ # Proc to apply a single condition
65
+ apply_single_condition = Proc.new do |condition, associated_query|
66
+ colo = condition[:model].columns_hash[condition[:name].to_s]
55
67
  column_prefix = condition[:column_prefix]
68
+ #Mark where clause referencing the appropriate alias
69
+ associated_query = associated_query.references(build_reference_value(column_prefix, query: associated_query))
70
+ self.class.add_clause(
71
+ query: associated_query,
72
+ column_prefix: column_prefix,
73
+ column_object: colo,
74
+ op: condition[:op],
75
+ value: condition[:value]
76
+ )
77
+ end
56
78
 
57
- colo = condition[:model].columns_hash[filter_name.to_s]
58
- add_clause(column_prefix: column_prefix, column_object: colo, op: condition[:op], value: filter_value)
79
+ if @active_record_version_maj < 6
80
+ # ActiveRecord < 6 does not support '.and' so no nested things can be done
81
+ # But we can still support the case of 1+ flat conditions of the same AND/OR type
82
+ if root_parent_group.is_a?(FilteringParams::Condition)
83
+ # A Single condition it is easy to handle
84
+ apply_single_condition.call(result[:conditions].first, query_with_joins)
85
+ elsif root_parent_group.items.all?{|i| i.is_a?(FilteringParams::Condition)}
86
+ # Only 1 top level root, with only with simple condition items
87
+ if root_parent_group.type == :and
88
+ result[:conditions].reverse.inject(query_with_joins) do |accum, condition|
89
+ apply_single_condition.call(condition, accum)
90
+ end
91
+ else
92
+ # To do a flat OR, we need to apply the first condition to the incoming query
93
+ # and then apply any extra ORs to it. Otherwise Book.or(X).or(X) still matches all books
94
+ cond1, *rest = result[:conditions].reverse
95
+ start_query = apply_single_condition.call(cond1, query_with_joins)
96
+ rest.inject(start_query) do |accum, condition|
97
+ accum.or(apply_single_condition.call(condition, query_with_joins))
98
+ end
99
+ end
100
+ else
101
+ raise "Mixing AND and OR conditions is not supported for ActiveRecord <6."
102
+ end
103
+ else # ActiveRecord 6+
104
+ # Process the conditions in a depth-first order, and return the resulting query
105
+ _depth_first_traversal(
106
+ root_query: query_with_joins,
107
+ root_node: root_parent_group,
108
+ conditions: result[:conditions],
109
+ &apply_single_condition
110
+ )
59
111
  end
60
112
  end
61
113
 
62
114
  private
115
+ def _depth_first_traversal(root_query:, root_node:, conditions:, &block)
116
+ # Save the associated query for non-leaves
117
+ root_node.associated_query = root_query if root_node.is_a?(FilteringParams::ConditionGroup)
118
+
119
+ if root_node.is_a?(FilteringParams::Condition)
120
+ matching_condition = conditions.find {|cond| cond[:node_object] == root_node }
121
+
122
+ # The simplified case of a single top level condition (without a wrapping group)
123
+ # will need to pass the root query itself
124
+ associated_query = root_node.parent_group ? root_node.parent_group.associated_query : root_query
125
+ return yield matching_condition, associated_query
126
+ else
127
+ first_query, *rest_queries = root_node.items.map do |child|
128
+ _depth_first_traversal(root_query: root_query, root_node: child, conditions: conditions, &block)
129
+ end
130
+
131
+ rest_queries.each.inject(first_query) do |q, a_query|
132
+ root_node.type == :and ? q.and(a_query) : q.or(a_query)
133
+ end
134
+ end
135
+ end
63
136
 
64
137
  def _mapped_filter(name)
65
138
  target = @filters_map[name]
@@ -89,7 +162,9 @@ module Praxis
89
162
  if mapped_value.is_a?(Proc)
90
163
  result = mapped_value.call(filter)
91
164
  # Result could be an array of hashes (each hash has name/op/value to identify a condition)
92
- result.is_a?(Array) ? result : [result]
165
+ result_from_proc = result.is_a?(Array) ? result : [result]
166
+ # Make sure we tack on the node object associated with the filter
167
+ result_from_proc.map{|hash| hash.merge(node_object: filter[:node_object])}
93
168
  else
94
169
  # For non-procs there's only 1 filter and 1 value (we're just overriding the mapped value)
95
170
  [filter.merge( name: mapped_value)]
@@ -117,51 +192,51 @@ module Praxis
117
192
  {associations_hash: h, conditions: conditions}
118
193
  end
119
194
 
120
- def add_clause(column_prefix:, column_object:, op:, value:)
121
- @query = @query.references(build_reference_value(column_prefix)) #Mark where clause referencing the appropriate alias
195
+ def self.add_clause(query:, column_prefix:, column_object:, op:, value:)
122
196
  likeval = get_like_value(value)
123
197
  case op
124
- when '!' # name! means => name IS NOT NULL (and the incoming value is nil)
125
- op = '!='
126
- value = nil # Enforce it is indeed nil (should be)
127
- when '!!'
128
- op = '='
129
- value = nil # Enforce it is indeed nil (should be)
198
+ when '!' # name! means => name IS NOT NULL (and the incoming value is nil)
199
+ op = '!='
200
+ value = nil # Enforce it is indeed nil (should be)
201
+ when '!!'
202
+ op = '='
203
+ value = nil # Enforce it is indeed nil (should be)
204
+ end
205
+
206
+ case op
207
+ when '='
208
+ if likeval
209
+ add_safe_where(query: query, tab: column_prefix, col: column_object, op: 'LIKE', value: likeval)
210
+ else
211
+ quoted_right = quote_right_part(query: query, value: value, column_object: column_object, negative: false)
212
+ query.where("#{quote_column_path(query: query, prefix: column_prefix, column_object: column_object)} #{quoted_right}")
130
213
  end
131
- @query = case op
132
- when '='
133
- if likeval
134
- add_safe_where(tab: column_prefix, col: column_object, op: 'LIKE', value: likeval)
135
- else
136
- quoted_right = quote_right_part(value: value, column_object: column_object, negative: false)
137
- query.where("#{quote_column_path(column_prefix, column_object)} #{quoted_right}")
138
- end
139
- when '!='
140
- if likeval
141
- add_safe_where(tab: column_prefix, col: column_object, op: 'NOT LIKE', value: likeval)
142
- else
143
- quoted_right = quote_right_part(value: value, column_object: column_object, negative: true)
144
- query.where("#{quote_column_path(column_prefix, column_object)} #{quoted_right}")
145
- end
146
- when '>'
147
- add_safe_where(tab: column_prefix, col: column_object, op: '>', value: value)
148
- when '<'
149
- add_safe_where(tab: column_prefix, col: column_object, op: '<', value: value)
150
- when '>='
151
- add_safe_where(tab: column_prefix, col: column_object, op: '>=', value: value)
152
- when '<='
153
- add_safe_where(tab: column_prefix, col: column_object, op: '<=', value: value)
154
- else
155
- raise "Unsupported Operator!!! #{op}"
156
- end
157
- end
158
-
159
- def add_safe_where(tab:, col:, op:, value:)
214
+ when '!='
215
+ if likeval
216
+ add_safe_where(query: query, tab: column_prefix, col: column_object, op: 'NOT LIKE', value: likeval)
217
+ else
218
+ quoted_right = quote_right_part(query: query, value: value, column_object: column_object, negative: true)
219
+ query.where("#{quote_column_path(query: query, prefix: column_prefix, column_object: column_object)} #{quoted_right}")
220
+ end
221
+ when '>'
222
+ add_safe_where(query: query, tab: column_prefix, col: column_object, op: '>', value: value)
223
+ when '<'
224
+ add_safe_where(query: query, tab: column_prefix, col: column_object, op: '<', value: value)
225
+ when '>='
226
+ add_safe_where(query: query, tab: column_prefix, col: column_object, op: '>=', value: value)
227
+ when '<='
228
+ add_safe_where(query: query, tab: column_prefix, col: column_object, op: '<=', value: value)
229
+ else
230
+ raise "Unsupported Operator!!! #{op}"
231
+ end
232
+ end
233
+
234
+ def self.add_safe_where(query:, tab:, col:, op:, value:)
160
235
  quoted_value = query.connection.quote_default_expression(value,col)
161
- query.where("#{quote_column_path(tab, col)} #{op} #{quoted_value}")
236
+ query.where("#{self.quote_column_path(query: query, prefix: tab, column_object: col)} #{op} #{quoted_value}")
162
237
  end
163
238
 
164
- def quote_column_path(prefix, column_object)
239
+ def self.quote_column_path(query:, prefix:, column_object:)
165
240
  c = query.connection
166
241
  quoted_column = c.quote_column_name(column_object.name)
167
242
  if prefix
@@ -172,7 +247,7 @@ module Praxis
172
247
  end
173
248
  end
174
249
 
175
- def quote_right_part(value:, column_object:, negative:)
250
+ def self.quote_right_part(query:, value:, column_object:, negative:)
176
251
  conn = query.connection
177
252
  if value.nil?
178
253
  no = negative ? ' NOT' : ''
@@ -190,7 +265,7 @@ module Praxis
190
265
  end
191
266
 
192
267
  # Returns nil if the value was not a fuzzzy pattern
193
- def get_like_value(value)
268
+ def self.get_like_value(value)
194
269
  if value.is_a?(String) && (value[-1] == '*' || value[0] == '*')
195
270
  likeval = value.dup
196
271
  likeval[-1] = '%' if value[-1] == '*'
@@ -203,14 +278,14 @@ module Praxis
203
278
  maj, min, _ = ActiveRecord.gem_version.segments
204
279
  if maj == 5 || (maj == 6 && min == 0)
205
280
  # In AR 6 (and 6.0) the references are simple strings
206
- def build_reference_value(column_prefix)
281
+ def build_reference_value(column_prefix, query: nil)
207
282
  column_prefix
208
283
  end
209
284
  else
210
285
  # The latest AR versions discard passing references to joins when they're not SqlLiterals ... so let's wrap it
211
286
  # with our class, so that it is a literal (already quoted), but that can still provide the expected "symbol" without quotes
212
287
  # so that our aliasing code can match it.
213
- def build_reference_value(column_prefix)
288
+ def build_reference_value(column_prefix, query:)
214
289
  QuasiSqlLiteral.new(quoted: query.connection.quote_table_name(column_prefix), symbolized: column_prefix.to_sym)
215
290
  end
216
291
  end
@@ -3,7 +3,8 @@ module Praxis
3
3
  module AttributeFiltering
4
4
  class FilterTreeNode
5
5
  attr_reader :path, :conditions, :children
6
- # # parsed_filters is an Array of {name: X, op: , value: } ... exactly the format of the FilteringParams.load method
6
+ # Parsed_filters is an Array of {name: X, op: Y, value: Z} ... exactly the format of the FilteringParams.load method
7
+ # It can also contain a :node_object
7
8
  def initialize(parsed_filters, path: [])
8
9
  @path = path # Array that marks the tree 'path' to this node (with respect to the absolute root)
9
10
  @conditions = [] # Conditions to apply directly to this node
@@ -14,7 +15,7 @@ module Praxis
14
15
  if components.empty?
15
16
  return
16
17
  elsif components.size == 1
17
- @conditions << hash.slice(:name, :op, :value)
18
+ @conditions << hash.slice(:name, :op, :value, :node_object)
18
19
  else
19
20
  children_data[components.first] ||= []
20
21
  children_data[components.first] << hash
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
- # rubocop:disable all
2
+ require 'praxis/extensions/attribute_filtering/filters_parser'
3
+
3
4
  #
4
- # Attributor type to define and handlea simple language to express filtering attributes in listings.
5
+ # Attributor type to define and handle the language to express filtering attributes in listings.
5
6
  # Commonly used in a query string parameter value for listing calls.
6
7
  #
7
8
  # The type allows you to restrict the allowable fields (and their types) based on an existing Mediatype.
@@ -14,7 +15,7 @@
14
15
  # attribute :filters,
15
16
  # Types::FilteringParams.for(MediaTypes::MyType) do
16
17
  # filter 'user.id', using: ['=', '!=']
17
- # filter 'name', using: ['=', '!=']
18
+ # filter 'name', using: ['=', '!=', '!', '!!]
18
19
  # filter 'children.created_at', using: ['>', '>=', '<', '<=']
19
20
  # filter 'display_name', using: ['=', '!='], fuzzy: true
20
21
  # end
@@ -26,6 +27,8 @@ module Praxis
26
27
  include Attributor::Type
27
28
  include Attributor::Dumpable
28
29
 
30
+ attr_reader :parsed_array
31
+
29
32
  class DSLCompiler < Attributor::DSLCompiler
30
33
  # "account.id": { operators: ["=", "!="] },
31
34
  # name: { operators: ["=", "!="], fuzzy_match: true },
@@ -36,9 +39,9 @@ module Praxis
36
39
  end
37
40
  end
38
41
 
39
- VALUE_REGEX = /[^,&]*/
40
- AVAILABLE_OPERATORS = Set.new(['!=', '>=', '<=', '=', '<', '>','!','!!']).freeze
41
- FILTER_REGEX = /(?<attribute>([^=!><])+)(?<operator>!=|>=|<=|!!|=|<|>|!)(?<value>#{VALUE_REGEX}(,#{VALUE_REGEX})*)/
42
+ VALUE_OPERATORS = Set.new(['!=', '>=', '<=', '=', '<', '>']).freeze
43
+ NOVALUE_OPERATORS = Set.new(['!','!!']).freeze
44
+ AVAILABLE_OPERATORS = Set.new(VALUE_OPERATORS+NOVALUE_OPERATORS).freeze
42
45
 
43
46
  # Abstract class, which needs to be used by subclassing it through the .for method, to set the allowed filters
44
47
  # definition should be a hash, keyed by field name, which contains a hash that can have two pieces of metadata
@@ -52,7 +55,7 @@ module Praxis
52
55
  def for(media_type, **_opts)
53
56
  unless media_type < Praxis::MediaType
54
57
  raise ArgumentError, "Invalid type: #{media_type.name} for Filters. " \
55
- 'Must be a subclass of MediaType'
58
+ 'Using the .for method for defining a filter, requires passing a subclass of a MediaType'
56
59
  end
57
60
 
58
61
  ::Class.new(self) do
@@ -77,9 +80,7 @@ module Praxis
77
80
  }
78
81
  end
79
82
  end
80
-
81
- attr_reader :parsed_array
82
-
83
+
83
84
  def self.native_type
84
85
  self
85
86
  end
@@ -134,11 +135,15 @@ module Praxis
134
135
  raise "filter with name #{filter_name} does not correspond to an existing field inside " \
135
136
  " MediaType #{media_type.name}"
136
137
  end
137
- attr_example = filter_components.inject(mt_example) do |last, name|
138
- # we can safely do sends, since we've verified the components are valid
139
- last.send(name)
140
- end
141
- arr << "#{filter_name}#{op}#{attr_example}"
138
+ if NOVALUE_OPERATORS.include?(op)
139
+ arr << "#{filter_name}#{op}" # Do not add a value for the operators that don't take it
140
+ else
141
+ attr_example = filter_components.inject(mt_example) do |last, name|
142
+ # we can safely do sends, since we've verified the components are valid
143
+ last.send(name)
144
+ end
145
+ arr << "#{filter_name}#{op}#{attr_example}"
146
+ end
142
147
  end.join('&')
143
148
  else
144
149
  'name=Joe&date>2017-01-01'
@@ -153,34 +158,32 @@ module Praxis
153
158
 
154
159
  def self.load(filters, _context = Attributor::DEFAULT_ROOT_CONTEXT, **_options)
155
160
  return filters if filters.is_a?(native_type)
156
- return new if filters.nil?
157
- parsed = filters.split('&').each_with_object([]) do |filter_string, arr|
158
- match = FILTER_REGEX.match(filter_string)
159
- values = CGI.unescape(match[:value]).split(',')
160
- value = if values.size > 1
161
- multimatch = true
162
- values
163
- else
164
- multimatch = false
165
- values.first
166
- end
167
-
168
- attr_name = match[:attribute].to_sym
169
- coerced = if media_type
170
- filter_components = attr_name.to_s.split('.').map(&:to_sym)
171
- attr, _enclosing_type = find_filter_attribute(filter_components, media_type)
172
- if multimatch
173
- attr_coll = Attributor::Collection.of(attr.type)
174
- attr_coll.load(value)
161
+ return new if filters.nil? || filters.blank?
162
+
163
+ parsed = Parser.new.parse(CGI.unescape(filters))
164
+ tree = ConditionGroup.load(parsed)
165
+
166
+ rr = tree.flattened_conditions
167
+ accum = []
168
+ rr.each do |spec|
169
+ attr_name = spec[:name]
170
+ # TODO: Do we need to CGI.unescape things? here or even before??...
171
+ coerced = \
172
+ if media_type
173
+ filter_components = attr_name.to_s.split('.').map(&:to_sym)
174
+ attr, _enclosing_type = find_filter_attribute(filter_components, media_type)
175
+ if spec[:values].is_a?(Array)
176
+ attr_coll = Attributor::Collection.of(attr.type)
177
+ attr_coll.load(spec[:values])
178
+ else
179
+ attr.load(spec[:values])
180
+ end
175
181
  else
176
- attr.load(value)
182
+ spec[:values]
177
183
  end
178
- else
179
- value
180
- end
181
- arr.push(name: attr_name, op: match[:operator], value: coerced )
184
+ accum.push(name: attr_name, op: spec[:op], value: coerced , node_object: spec[:node_object])
182
185
  end
183
- new(parsed)
186
+ new(accum)
184
187
  end
185
188
 
186
189
  def self.dump(value, **_opts)
@@ -0,0 +1,153 @@
1
+ require 'parslet'
2
+
3
+ module Praxis
4
+ module Extensions
5
+ module AttributeFiltering
6
+ class FilteringParams
7
+ class Condition
8
+ attr_reader :name, :op, :values
9
+ attr_accessor :parent_group
10
+
11
+ # For operands with a single or no values: Incoming data is a hash with name and op
12
+ # For Operands with multiple values: Incoming data is an array of hashes
13
+ # First hash has the spec (i.e., name and op)
14
+ # The rest of the hashes contain a value each (a hash with value: X each).
15
+ # Example: [{:name=>"multi"@0, :op=>"="@5}, {:value=>"1"@6}, {:value=>"2"@8}]
16
+ def initialize(triad:, parent_group:)
17
+ @parent_group = parent_group
18
+
19
+ if triad.is_a? Array # several values coming in
20
+ spec, *values = triad
21
+ @name = spec[:name].to_sym
22
+ @op = spec[:op].to_s
23
+
24
+ @values = if values.empty?
25
+ ""
26
+ elsif values.size == 1
27
+ values.first[:value].to_s
28
+ else
29
+ values.map{|e| e[:value].to_s}
30
+ end
31
+ else # No values for the operand
32
+ @name = triad[:name].to_sym
33
+ @op = triad[:op].to_s
34
+ if ['!','!!'].include?(@op)
35
+ @values = nil
36
+ else
37
+ # Value operand without value? => convert it to empty string
38
+ raise "Interesting, didn't know this could happen. Oops!" if triad[:value].is_a?(Array) && !triad[:value].empty?
39
+ @values = (triad[:value] == []) ? '' : triad[:value].to_s # TODO: could this be an array (or it always comes the other if)
40
+ end
41
+ end
42
+ end
43
+
44
+ def flattened_conditions
45
+ [{name: @name, op: @op, values: @values, node_object: self}]
46
+ end
47
+
48
+ def dump
49
+ vals = if values.is_a? Array
50
+ "[#{values.join(',')}]" # Purposedly enclose in brackets to make sure we differentiate
51
+ else
52
+ (values == '') ? "\"#{values}\"" : values # Dump the empty string explicitly with quotes if we've converted no value to empty string
53
+ end
54
+ "#{name}#{op}#{vals}"
55
+ end
56
+ end
57
+
58
+ # An Object that represents an AST tree for either an OR or an AND conditions
59
+ # to be applied to its items children
60
+ class ConditionGroup
61
+ attr_reader :items, :type
62
+ attr_accessor :parent_group
63
+ attr_accessor :associated_query # Metadata to be used by whomever is manipulating this
64
+
65
+ def self.load(node)
66
+ unless node[:o]
67
+ loaded = Condition.new(triad: node[:triad], parent_group: nil)
68
+ else
69
+ compactedl = compress_tree(node: node[:l], op: node[:o])
70
+ compactedr = compress_tree(node: node[:r], op: node[:o])
71
+ compacted = {op: node[:o], items: compactedl + compactedr }
72
+
73
+ loaded = ConditionGroup.new(**compacted, parent_group: nil)
74
+ end
75
+ loaded
76
+ end
77
+
78
+ def initialize(op:, items:, parent_group:)
79
+ @type = (op.to_s == '&') ? :and : :or
80
+ @items = items.map do |item|
81
+ if item[:op]
82
+ ConditionGroup.new(**item, parent_group: self)
83
+ else
84
+ Condition.new(triad: item[:triad], parent_group: self)
85
+ end
86
+ end
87
+ @parent_group = parent_group
88
+ end
89
+
90
+ def dump
91
+ "( " + @items.map(&:dump).join(" #{type.upcase} ") + " )"
92
+ end
93
+
94
+ # Returns an array with flat conditions from all child triad conditions
95
+ def flattened_conditions
96
+ @items.inject([]) do |accum, item|
97
+ accum + item.flattened_conditions
98
+ end
99
+ end
100
+
101
+ # Given a binary tree of operand conditions, transform it to a multi-leaf tree
102
+ # where a single condition node has potentially multiple subtrees for the same operation (instead of 2)
103
+ # For example (&, (&, a, b), (|, c, d)) => (&, a, b, (|, c, d))
104
+ def self.compress_tree(node:, op:)
105
+ if node[:triad]
106
+ return [node]
107
+ end
108
+
109
+ # It is an op node
110
+ if node[:o] == op
111
+ # compatible op as parent, collect my compacted children and return them up skipping my op
112
+ resultl = compress_tree(node: node[:l], op: op)
113
+ resultr = compress_tree(node: node[:r], op: op)
114
+ resultl+resultr
115
+ else
116
+ collected = compress_tree(node: node, op: node[:o])
117
+ [{op: node[:o], items: collected }]
118
+ end
119
+ end
120
+ end
121
+
122
+ class Parser < Parslet::Parser
123
+ root :expression
124
+ rule(:lparen) { str('(') }
125
+ rule(:rparen) { str(')') }
126
+ rule(:comma) { str(',') }
127
+ rule(:val_operator) { str('!=') | str('>=') | str('<=') | str('=') | str('<') | str('>')}
128
+ rule(:noval_operator) { str('!!') | str('!')}
129
+ rule(:and_kw) { str('&') }
130
+ rule(:or_kw) { str('|') }
131
+
132
+ def infix *args
133
+ Infix.new(*args)
134
+ end
135
+
136
+ 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) }
138
+ rule(:value) { chars >> (comma >> chars ).repeat }
139
+
140
+ rule(:triad) {
141
+ (name.as(:name) >> val_operator.as(:op) >> value).as(:triad) |
142
+ (name.as(:name) >> noval_operator.as(:op)).as(:triad) |
143
+ lparen >> expression >> rparen
144
+ }
145
+
146
+ rule(:expression) {
147
+ infix_expression(triad, [and_kw, 2, :left], [or_kw, 1, :right])
148
+ }
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end