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