Fix 2.4.2 upgrade notes formatting refs #19330
[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 the following 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   # :joins     array of joins: either [] or ["JOIN containers ON ..."]
26   def record_filters filters, model_class
27     conds_out = []
28     param_out = []
29     joins = []
30
31     model_table_name = model_class.table_name
32     filters.each do |filter|
33       attrs_in, operator, operand = filter
34       if operator == '@@'
35         raise ArgumentError.new("Full text search operator is no longer supported")
36       end
37       if attrs_in == 'any'
38         attrs = model_class.searchable_columns(operator)
39       elsif attrs_in.is_a? Array
40         attrs = attrs_in
41       else
42         attrs = [attrs_in]
43       end
44       if !filter.is_a? Array
45         raise ArgumentError.new("Invalid element in filters array: #{filter.inspect} is not an array")
46       elsif !operator.is_a? String
47         raise ArgumentError.new("Invalid operator '#{operator}' (#{operator.class}) in filter")
48       end
49
50       operator = operator.downcase
51       cond_out = []
52
53       if attrs_in == 'any' && (operator == 'ilike' || operator == 'like') && (operand.is_a? String) && operand.match('^[%].*[%]$')
54         # Trigram index search
55         cond_out << model_class.full_text_trgm + " #{operator} ?"
56         param_out << operand
57         # Skip the generic per-column operator loop below
58         attrs = []
59       end
60
61       attrs.each do |attr|
62         subproperty = attr.split(".", 2)
63
64         if subproperty.length == 2 && subproperty[0] == 'container' && model_table_name == "container_requests"
65           # attr is "tablename.colname" -- e.g., ["container.state", "=", "Complete"]
66           joins = ["JOIN containers ON container_requests.container_uuid = containers.uuid"]
67           attr_model_class = Container
68           attr_table_name = "containers"
69           subproperty = subproperty[1].split(".", 2)
70         else
71           attr_model_class = model_class
72           attr_table_name = model_table_name
73         end
74
75         attr = subproperty[0]
76         proppath = subproperty[1]
77         col = attr_model_class.columns.select { |c| c.name == attr }.first
78
79         if proppath
80           if col.nil? or col.type != :jsonb
81             raise ArgumentError.new("Invalid attribute '#{attr}' for subproperty filter")
82           end
83
84           if proppath[0] == "<" and proppath[-1] == ">"
85             proppath = proppath[1..-2]
86           end
87
88           # jsonb search
89           case operator
90           when '=', '!='
91             not_in = if operator == "!=" then "NOT " else "" end
92             cond_out << "#{not_in}(#{attr_table_name}.#{attr} @> ?::jsonb)"
93             param_out << SafeJSON.dump({proppath => operand})
94           when 'in'
95             if operand.is_a? Array
96               operand.each do |opr|
97                 cond_out << "#{attr_table_name}.#{attr} @> ?::jsonb"
98                 param_out << SafeJSON.dump({proppath => opr})
99               end
100             else
101               raise ArgumentError.new("Invalid operand type '#{operand.class}' "\
102                                       "for '#{operator}' operator in filters")
103             end
104           when '<', '<=', '>', '>='
105             cond_out << "#{attr_table_name}.#{attr}->? #{operator} ?::jsonb"
106             param_out << proppath
107             param_out << SafeJSON.dump(operand)
108           when 'like', 'ilike'
109             cond_out << "#{attr_table_name}.#{attr}->>? #{operator} ?"
110             param_out << proppath
111             param_out << operand
112           when 'not in'
113             if operand.is_a? Array
114               cond_out << "#{attr_table_name}.#{attr}->>? NOT IN (?) OR #{attr_table_name}.#{attr}->>? IS NULL"
115               param_out << proppath
116               param_out << operand
117               param_out << proppath
118             else
119               raise ArgumentError.new("Invalid operand type '#{operand.class}' "\
120                                       "for '#{operator}' operator in filters")
121             end
122           when 'exists'
123             if operand == true
124               cond_out << "jsonb_exists(#{attr_table_name}.#{attr}, ?)"
125             elsif operand == false
126               cond_out << "(NOT jsonb_exists(#{attr_table_name}.#{attr}, ?)) OR #{attr_table_name}.#{attr} is NULL"
127             else
128               raise ArgumentError.new("Invalid operand '#{operand}' for '#{operator}' must be true or false")
129             end
130             param_out << proppath
131           when 'contains'
132             cond_out << "#{attr_table_name}.#{attr} @> ?::jsonb OR #{attr_table_name}.#{attr} @> ?::jsonb"
133             param_out << SafeJSON.dump({proppath => operand})
134             param_out << SafeJSON.dump({proppath => [operand]})
135           else
136             raise ArgumentError.new("Invalid operator for subproperty search '#{operator}'")
137           end
138         elsif operator == "exists"
139           if col.nil? or col.type != :jsonb
140             raise ArgumentError.new("Invalid attribute '#{attr}' for operator '#{operator}' in filter")
141           end
142
143           cond_out << "jsonb_exists(#{attr_table_name}.#{attr}, ?)"
144           param_out << operand
145         elsif expr = /^ *\( *(\w+) *(<=?|>=?|=) *(\w+) *\) *$/.match(attr)
146           if operator != '=' || ![true,"true"].index(operand)
147             raise ArgumentError.new("Invalid expression filter '#{attr}': subsequent elements must be [\"=\", true]")
148           end
149           operator = expr[2]
150           attr1, attr2 = expr[1], expr[3]
151           allowed = attr_model_class.searchable_columns(operator)
152           [attr1, attr2].each do |tok|
153             if !allowed.index(tok)
154               raise ArgumentError.new("Invalid attribute in expression: '#{tok}'")
155             end
156             col = attr_model_class.columns.select { |c| c.name == tok }.first
157             if col.type != :integer
158               raise ArgumentError.new("Non-numeric attribute in expression: '#{tok}'")
159             end
160           end
161           cond_out << "#{attr1} #{operator} #{attr2}"
162         else
163           if !attr_model_class.searchable_columns(operator).index(attr) &&
164              !(col.andand.type == :jsonb && ['contains', '=', '<>', '!='].index(operator))
165             raise ArgumentError.new("Invalid attribute '#{attr}' in filter")
166           end
167
168           case operator
169           when '=', '<', '<=', '>', '>=', '!=', 'like', 'ilike'
170             attr_type = attr_model_class.attribute_column(attr).type
171             operator = '<>' if operator == '!='
172             if operand.is_a? String
173               if attr_type == :boolean
174                 if not ['=', '<>'].include?(operator)
175                   raise ArgumentError.new("Invalid operator '#{operator}' for " \
176                                           "boolean attribute '#{attr}'")
177                 end
178                 case operand.downcase
179                 when '1', 't', 'true', 'y', 'yes'
180                   operand = true
181                 when '0', 'f', 'false', 'n', 'no'
182                   operand = false
183                 else
184                   raise ArgumentError("Invalid operand '#{operand}' for " \
185                                       "boolean attribute '#{attr}'")
186                 end
187               end
188               if operator == '<>'
189                 # explicitly allow NULL
190                 cond_out << "#{attr_table_name}.#{attr} #{operator} ? OR #{attr_table_name}.#{attr} IS NULL"
191               else
192                 cond_out << "#{attr_table_name}.#{attr} #{operator} ?"
193               end
194               if (# any operator that operates on value rather than
195                 # representation:
196                 operator.match(/[<=>]/) and (attr_type == :datetime))
197                 operand = Time.parse operand
198               end
199               param_out << operand
200             elsif operand.nil? and operator == '='
201               cond_out << "#{attr_table_name}.#{attr} is null"
202             elsif operand.nil? and operator == '<>'
203               cond_out << "#{attr_table_name}.#{attr} is not null"
204             elsif (attr_type == :boolean) and ['=', '<>'].include?(operator) and
205                  [true, false].include?(operand)
206               cond_out << "#{attr_table_name}.#{attr} #{operator} ?"
207               param_out << operand
208             elsif (attr_type == :integer)
209               cond_out << "#{attr_table_name}.#{attr} #{operator} ?"
210               param_out << operand
211             else
212               raise ArgumentError.new("Invalid operand type '#{operand.class}' "\
213                                       "for '#{operator}' operator in filters")
214             end
215           when 'in', 'not in'
216             if operand.is_a? Array
217               cond_out << "#{attr_table_name}.#{attr} #{operator} (?)"
218               param_out << operand
219               if operator == 'not in' and not operand.include?(nil)
220                 # explicitly allow NULL
221                 cond_out[-1] = "(#{cond_out[-1]} OR #{attr_table_name}.#{attr} IS NULL)"
222               end
223             else
224               raise ArgumentError.new("Invalid operand type '#{operand.class}' "\
225                                       "for '#{operator}' operator in filters")
226             end
227           when 'is_a'
228             operand = [operand] unless operand.is_a? Array
229             cond = []
230             operand.each do |op|
231               cl = ArvadosModel::kind_class op
232               if cl
233                 if attr == 'uuid'
234                   if attr_model_class.uuid_prefix == cl.uuid_prefix
235                     cond << "1=1"
236                   else
237                     cond << "1=0"
238                   end
239                 else
240                   # Use a substring query to support remote uuids
241                   cond << "substring(#{attr_table_name}.#{attr}, 7, 5) = ?"
242                   param_out << cl.uuid_prefix
243                 end
244               else
245                 cond << "1=0"
246               end
247             end
248             cond_out << cond.join(' OR ')
249           when 'contains'
250             if col.andand.type != :jsonb
251               raise ArgumentError.new("Invalid attribute '#{attr}' for '#{operator}' operator")
252             end
253             if operand == []
254               raise ArgumentError.new("Invalid operand '#{operand.inspect}' for '#{operator}' operator")
255             end
256             operand = [operand] unless operand.is_a? Array
257             operand.each do |op|
258               if !op.is_a?(String)
259                 raise ArgumentError.new("Invalid element #{operand.inspect} in operand for #{operator.inspect} operator (operand must be a string or array of strings)")
260               end
261             end
262             # We use jsonb_exists_all(a,b) instead of "a ?& b" because
263             # the pg gem thinks "?" is a bind var. And we use string
264             # interpolation instead of param_out because the pg gem
265             # flattens param_out / doesn't support passing arrays as
266             # bind vars.
267             q = operand.map { |s| ActiveRecord::Base.connection.quote(s) }.join(',')
268             cond_out << "jsonb_exists_all(#{attr_table_name}.#{attr}, array[#{q}])"
269           else
270             raise ArgumentError.new("Invalid operator '#{operator}'")
271           end
272         end
273       end
274       conds_out << cond_out.join(' OR ') if cond_out.any?
275     end
276
277     {:cond_out => conds_out, :param_out => param_out, :joins => joins}
278   end
279
280 end