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