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