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 +4 -4
- data/.ruby-version +1 -1
- data/CHANGELOG.md +4 -0
- data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +130 -55
- data/lib/praxis/extensions/attribute_filtering/filter_tree_node.rb +3 -2
- data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +43 -40
- data/lib/praxis/extensions/attribute_filtering/filters_parser.rb +153 -0
- data/lib/praxis/version.rb +1 -1
- data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +243 -173
- data/spec/praxis/extensions/attribute_filtering/filter_tree_node_spec.rb +25 -6
- data/spec/praxis/extensions/attribute_filtering/filtering_params_spec.rb +78 -17
- data/spec/praxis/extensions/attribute_filtering/filters_parser_spec.rb +131 -0
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: cf4b54b686373509e0920a93d482e29f4c198491f88d382b6ff429e5128d6574
|
4
|
+
data.tar.gz: '08b2d7342069e8584676e03c828e144b99eb9282b193113b987d8060cb143cfd'
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ef7cd7fe985d4af76cf7ec6459e58990b8dbc6f3c4fac5c26ce04d56e23a3d743a0f4bb776f4d5c913224e031567e9993ad52e611e4258ee853cdf86b3099255
|
7
|
+
data.tar.gz: f4fe45f97c9bc26aa513a24ee358b04dc114af0e7f4849071f699c0c22df460253452dd54e9bcb510fc4f977aa9104774f70c882f46b8aa937404e79814d6f3d
|
data/.ruby-version
CHANGED
@@ -1 +1 @@
|
|
1
|
-
2.
|
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 :
|
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
|
-
|
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: ",
|
45
|
-
|
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
|
-
@
|
52
|
+
return @initial_query if result[:conditions].empty?
|
51
53
|
|
52
|
-
|
53
|
-
|
54
|
-
|
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
|
-
|
58
|
-
|
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
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
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
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
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
|
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
|
-
#
|
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
|
-
|
2
|
+
require 'praxis/extensions/attribute_filtering/filters_parser'
|
3
|
+
|
3
4
|
#
|
4
|
-
# Attributor type to define and
|
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
|
-
|
40
|
-
|
41
|
-
|
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
|
-
'
|
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
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
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
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
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
|
-
|
182
|
+
spec[:values]
|
177
183
|
end
|
178
|
-
|
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(
|
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
|