Bump loofah from 2.2.3 to 2.3.1 in /apps/workbench
[arvados.git] / services / api / lib / record_filters.rb
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: AGPL-3.0
4
5 # Mixin module providing a method to convert filters into a list of SQL
6 # fragments suitable to be fed to ActiveRecord #where.
7 #
8 # Expects:
9 #   model_class
10 # Operates on:
11 #   @objects
12
13 require 'safe_json'
14
15 module RecordFilters
16
17   # Input:
18   # +filters+        array of conditions, each being [column, operator, operand]
19   # +model_class+    subclass of ActiveRecord being filtered
20   #
21   # Output:
22   # Hash with two keys:
23   # :cond_out  array of SQL fragments for each filter expression
24   # :param_out  array of values for parameter substitution in cond_out
25   def record_filters filters, model_class
26     conds_out = []
27     param_out = []
28
29     ar_table_name = model_class.table_name
30     filters.each do |filter|
31       attrs_in, operator, operand = filter
32       if attrs_in == 'any' && operator != '@@'
33         attrs = model_class.searchable_columns(operator)
34       elsif attrs_in.is_a? Array
35         attrs = attrs_in
36       else
37         attrs = [attrs_in]
38       end
39       if !filter.is_a? Array
40         raise ArgumentError.new("Invalid element in filters array: #{filter.inspect} is not an array")
41       elsif !operator.is_a? String
42         raise ArgumentError.new("Invalid operator '#{operator}' (#{operator.class}) in filter")
43       end
44
45       cond_out = []
46
47       if attrs_in == 'any' && (operator.casecmp('ilike').zero? || operator.casecmp('like').zero?) && (operand.is_a? String) && operand.match('^[%].*[%]$')
48         # Trigram index search
49         cond_out << model_class.full_text_trgm + " #{operator} ?"
50         param_out << operand
51         # Skip the generic per-column operator loop below
52         attrs = []
53       end
54
55       if operator == '@@'
56         # Full-text search
57         if attrs_in != 'any'
58           raise ArgumentError.new("Full text search on individual columns is not supported")
59         end
60         if operand.is_a? Array
61           raise ArgumentError.new("Full text search not supported for array operands")
62         end
63
64         # Skip the generic per-column operator loop below
65         attrs = []
66         # Use to_tsquery since plainto_tsquery does not support prefix
67         # search. And, split operand and join the words with ' & '
68         cond_out << model_class.full_text_tsvector+" @@ to_tsquery(?)"
69         param_out << operand.split.join(' & ')
70       end
71       attrs.each do |attr|
72         subproperty = attr.split(".", 2)
73
74         col = model_class.columns.select { |c| c.name == subproperty[0] }.first
75
76         if subproperty.length == 2
77           if col.nil? or col.type != :jsonb
78             raise ArgumentError.new("Invalid attribute '#{subproperty[0]}' for subproperty filter")
79           end
80
81           if subproperty[1][0] == "<" and subproperty[1][-1] == ">"
82             subproperty[1] = subproperty[1][1..-2]
83           end
84
85           # jsonb search
86           case operator.downcase
87           when '=', '!='
88             not_in = if operator.downcase == "!=" then "NOT " else "" end
89             cond_out << "#{not_in}(#{ar_table_name}.#{subproperty[0]} @> ?::jsonb)"
90             param_out << SafeJSON.dump({subproperty[1] => operand})
91           when 'in'
92             if operand.is_a? Array
93               operand.each do |opr|
94                 cond_out << "#{ar_table_name}.#{subproperty[0]} @> ?::jsonb"
95                 param_out << SafeJSON.dump({subproperty[1] => opr})
96               end
97             else
98               raise ArgumentError.new("Invalid operand type '#{operand.class}' "\
99                                       "for '#{operator}' operator in filters")
100             end
101           when '<', '<=', '>', '>='
102             cond_out << "#{ar_table_name}.#{subproperty[0]}->? #{operator} ?::jsonb"
103             param_out << subproperty[1]
104             param_out << SafeJSON.dump(operand)
105           when 'like', 'ilike'
106             cond_out << "#{ar_table_name}.#{subproperty[0]}->>? #{operator} ?"
107             param_out << subproperty[1]
108             param_out << operand
109           when 'not in'
110             if operand.is_a? Array
111               cond_out << "#{ar_table_name}.#{subproperty[0]}->>? NOT IN (?) OR #{ar_table_name}.#{subproperty[0]}->>? IS NULL"
112               param_out << subproperty[1]
113               param_out << operand
114               param_out << subproperty[1]
115             else
116               raise ArgumentError.new("Invalid operand type '#{operand.class}' "\
117                                       "for '#{operator}' operator in filters")
118             end
119           when 'exists'
120             if operand == true
121               cond_out << "jsonb_exists(#{ar_table_name}.#{subproperty[0]}, ?)"
122             elsif operand == false
123               cond_out << "(NOT jsonb_exists(#{ar_table_name}.#{subproperty[0]}, ?)) OR #{ar_table_name}.#{subproperty[0]} is NULL"
124             else
125               raise ArgumentError.new("Invalid operand '#{operand}' for '#{operator}' must be true or false")
126             end
127             param_out << subproperty[1]
128           else
129             raise ArgumentError.new("Invalid operator for subproperty search '#{operator}'")
130           end
131         elsif operator.downcase == "exists"
132           if col.type != :jsonb
133             raise ArgumentError.new("Invalid attribute '#{subproperty[0]}' for operator '#{operator}' in filter")
134           end
135
136           cond_out << "jsonb_exists(#{ar_table_name}.#{subproperty[0]}, ?)"
137           param_out << operand
138         else
139           if !model_class.searchable_columns(operator).index subproperty[0]
140             raise ArgumentError.new("Invalid attribute '#{subproperty[0]}' in filter")
141           end
142
143           case operator.downcase
144           when '=', '<', '<=', '>', '>=', '!=', 'like', 'ilike'
145             attr_type = model_class.attribute_column(attr).type
146             operator = '<>' if operator == '!='
147             if operand.is_a? String
148               if attr_type == :boolean
149                 if not ['=', '<>'].include?(operator)
150                   raise ArgumentError.new("Invalid operator '#{operator}' for " \
151                                           "boolean attribute '#{attr}'")
152                 end
153                 case operand.downcase
154                 when '1', 't', 'true', 'y', 'yes'
155                   operand = true
156                 when '0', 'f', 'false', 'n', 'no'
157                   operand = false
158                 else
159                   raise ArgumentError("Invalid operand '#{operand}' for " \
160                                       "boolean attribute '#{attr}'")
161                 end
162               end
163               if operator == '<>'
164                 # explicitly allow NULL
165                 cond_out << "#{ar_table_name}.#{attr} #{operator} ? OR #{ar_table_name}.#{attr} IS NULL"
166               else
167                 cond_out << "#{ar_table_name}.#{attr} #{operator} ?"
168               end
169               if (# any operator that operates on value rather than
170                 # representation:
171                 operator.match(/[<=>]/) and (attr_type == :datetime))
172                 operand = Time.parse operand
173               end
174               param_out << operand
175             elsif operand.nil? and operator == '='
176               cond_out << "#{ar_table_name}.#{attr} is null"
177             elsif operand.nil? and operator == '<>'
178               cond_out << "#{ar_table_name}.#{attr} is not null"
179             elsif (attr_type == :boolean) and ['=', '<>'].include?(operator) and
180                  [true, false].include?(operand)
181               cond_out << "#{ar_table_name}.#{attr} #{operator} ?"
182               param_out << operand
183             elsif (attr_type == :integer)
184               cond_out << "#{ar_table_name}.#{attr} #{operator} ?"
185               param_out << operand
186             else
187               raise ArgumentError.new("Invalid operand type '#{operand.class}' "\
188                                       "for '#{operator}' operator in filters")
189             end
190           when 'in', 'not in'
191             if operand.is_a? Array
192               cond_out << "#{ar_table_name}.#{attr} #{operator} (?)"
193               param_out << operand
194               if operator == 'not in' and not operand.include?(nil)
195                 # explicitly allow NULL
196                 cond_out[-1] = "(#{cond_out[-1]} OR #{ar_table_name}.#{attr} IS NULL)"
197               end
198             else
199               raise ArgumentError.new("Invalid operand type '#{operand.class}' "\
200                                       "for '#{operator}' operator in filters")
201             end
202           when 'is_a'
203             operand = [operand] unless operand.is_a? Array
204             cond = []
205             operand.each do |op|
206               cl = ArvadosModel::kind_class op
207               if cl
208                 if attr == 'uuid'
209                   if model_class.uuid_prefix == cl.uuid_prefix
210                     cond << "1=1"
211                   else
212                     cond << "1=0"
213                   end
214                 else
215                   # Use a substring query to support remote uuids
216                   cond << "substring(#{ar_table_name}.#{attr}, 7, 5) = ?"
217                   param_out << cl.uuid_prefix
218                 end
219               else
220                 cond << "1=0"
221               end
222             end
223             cond_out << cond.join(' OR ')
224           else
225             raise ArgumentError.new("Invalid operator '#{operator}'")
226           end
227         end
228       end
229       conds_out << cond_out.join(' OR ') if cond_out.any?
230     end
231
232     {:cond_out => conds_out, :param_out => param_out}
233   end
234
235 end