Merge branch '17994-filter-by-storage-classes' into main
authorTom Clegg <tom@curii.com>
Mon, 30 Aug 2021 17:27:03 +0000 (13:27 -0400)
committerTom Clegg <tom@curii.com>
Mon, 30 Aug 2021 17:27:03 +0000 (13:27 -0400)
closes #17994

Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom@curii.com>

1  2 
doc/api/methods.html.textile.liquid
services/api/lib/record_filters.rb
services/api/test/functional/arvados/v1/filters_test.rb

index 670a9e0da3d96ed16f8de9e053ae5a746cf0aa31,7ca216bf13d9ccb27f18b1f1122374eda7d47913..e051ab66fa7afa18d8e52b09741c292f5e1faa9c
@@@ -96,16 -96,20 +96,20 @@@ table(table table-bordered table-conden
  |1|operator|string|Comparison operator|@>@, @>=@, @like@, @not in@|
  |2|operand|string, array, or null|Value to compare with the resource attribute|@"d00220fb%"@, @"1234"@, @["foo","bar"]@, @nil@|
  
 -The following operators are available.[1]
 +The following operators are available.
  
  table(table table-bordered table-condensed).
  |_. Operator|_. Operand type|_. Description|_. Example|
- |@=@, @!=@|string, number, timestamp, or null|Equality comparison|@["tail_uuid","=","xyzzy-j7d0g-fffffffffffffff"]@ @["tail_uuid","!=",null]@|
+ |@=@, @!=@, @<>@|string, number, timestamp, JSON-encoded array, JSON-encoded object, or null|Equality comparison|@["tail_uuid","=","xyzzy-j7d0g-fffffffffffffff"]@
+ @["tail_uuid","!=",null]@
+ @["storage_classes_desired","=","[\"default\"]"]@|
  |@<@, @<=@, @>=@, @>@|string, number, or timestamp|Ordering comparison|@["script_version",">","123"]@|
  |@like@, @ilike@|string|SQL pattern match.  Single character match is @_@ and wildcard is @%@. The @ilike@ operator is case-insensitive|@["script_version","like","d00220fb%"]@|
  |@in@, @not in@|array of strings|Set membership|@["script_version","in",["main","d00220fb38d4b85ca8fc28a8151702a2b9d1dec5"]]@|
  |@is_a@|string|Arvados object type|@["head_uuid","is_a","arvados#collection"]@|
- |@exists@|string|Test if a subproperty is present.|@["properties","exists","my_subproperty"]@|
+ |@exists@|string|Presence of subproperty|@["properties","exists","my_subproperty"]@|
+ |@contains@|string, array of strings|Presence of one or more keys or array elements|@["storage_classes_desired", "contains", ["foo", "bar"]]@ (matches both @["foo", "bar"]@ and @["foo", "bar", "baz"]@)
+ (note @[..., "contains", "foo"]@ is also accepted, and is equivalent to @[..., "contains", ["foo"]]@)|
  
  h4(#substringsearchfilter). Filtering using substring search
  
@@@ -128,7 -132,7 +132,7 @@@ table(table table-bordered table-conden
  |@like@, @ilike@|string|SQL pattern match, single character match is @_@ and wildcard is @%@, ilike is case-insensitive|@["properties.my_subproperty", "like", "d00220fb%"]@|
  |@in@, @not in@|array of strings|Set membership|@["properties.my_subproperty", "in", ["fizz", "buzz"]]@|
  |@exists@|boolean|Test if a subproperty is present or not (determined by operand).|@["properties.my_subproperty", "exists", true]@|
- |@contains@|string, number|Filter where subproperty has a value either by exact match or value is element of subproperty list.|@["foo", "contains", "bar"]@ will find both @{"foo": "bar"}@ and @{"foo": ["bar", "baz"]}@.|
+ |@contains@|string, number|Filter where subproperty has a value either by exact match or value is element of subproperty list.|@["properties.foo", "contains", "bar"]@ will find both @{"foo": "bar"}@ and @{"foo": ["bar", "baz"]}@.|
  
  Note that exclusion filters @!=@ and @not in@ will return records for which the property is not defined at all.  To restrict filtering to records on which the subproperty is defined, combine with an @exists@ filter.
  
@@@ -167,3 -171,5 +171,3 @@@ table(table table-bordered table-conden
  |_. Argument |_. Type |_. Description |_. Location |
  {background:#ccffcc}.|uuid|string|The UUID of the resource in question.|path||
  |{resource_type}|object||query||
 -
 -fn1^. NOTE: The filter operator for full-text search (@@) which previously worked (but was undocumented) is deprecated and will be removed in a future release.
index f8898d63c90de2169fc8d18b53d40f68171ae945,8f4244f5b85a892fef270d7818d0eada7d972c0f..409e48a6f090a3b348cd5d551bf35a91427e42a9
@@@ -31,10 -31,7 +31,10 @@@ module RecordFilter
      model_table_name = model_class.table_name
      filters.each do |filter|
        attrs_in, operator, operand = filter
 -      if attrs_in == 'any' && operator != '@@'
 +      if operator == '@@'
 +        raise ArgumentError.new("Full text search operator is no longer supported")
 +      end
 +      if attrs_in == 'any'
          attrs = model_class.searchable_columns(operator)
        elsif attrs_in.is_a? Array
          attrs = attrs_in
          raise ArgumentError.new("Invalid operator '#{operator}' (#{operator.class}) in filter")
        end
  
+       operator = operator.downcase
        cond_out = []
  
-       if attrs_in == 'any' && (operator.casecmp('ilike').zero? || operator.casecmp('like').zero?) && (operand.is_a? String) && operand.match('^[%].*[%]$')
+       if attrs_in == 'any' && (operator == 'ilike' || operator == 'like') && (operand.is_a? String) && operand.match('^[%].*[%]$')
          # Trigram index search
          cond_out << model_class.full_text_trgm + " #{operator} ?"
          param_out << operand
          attrs = []
        end
  
 -      if operator == '@@'
 -        # Full-text search
 -        if attrs_in != 'any'
 -          raise ArgumentError.new("Full text search on individual columns is not supported")
 -        end
 -        if operand.is_a? Array
 -          raise ArgumentError.new("Full text search not supported for array operands")
 -        end
 -
 -        # Skip the generic per-column operator loop below
 -        attrs = []
 -        # Use to_tsquery since plainto_tsquery does not support prefix
 -        # search. And, split operand and join the words with ' & '
 -        cond_out << model_class.full_text_tsvector+" @@ to_tsquery(?)"
 -        param_out << operand.split.join(' & ')
 -      end
        attrs.each do |attr|
          subproperty = attr.split(".", 2)
  
@@@ -85,9 -99,9 +86,9 @@@
            end
  
            # jsonb search
-           case operator.downcase
+           case operator
            when '=', '!='
-             not_in = if operator.downcase == "!=" then "NOT " else "" end
+             not_in = if operator == "!=" then "NOT " else "" end
              cond_out << "#{not_in}(#{attr_table_name}.#{attr} @> ?::jsonb)"
              param_out << SafeJSON.dump({proppath => operand})
            when 'in'
            else
              raise ArgumentError.new("Invalid operator for subproperty search '#{operator}'")
            end
-         elsif operator.downcase == "exists"
+         elsif operator == "exists"
            if col.type != :jsonb
              raise ArgumentError.new("Invalid attribute '#{attr}' for operator '#{operator}' in filter")
            end
            cond_out << "jsonb_exists(#{attr_table_name}.#{attr}, ?)"
            param_out << operand
          else
-           if !attr_model_class.searchable_columns(operator).index attr
+           if !attr_model_class.searchable_columns(operator).index(attr) &&
+              !(col.andand.type == :jsonb && ['contains', '=', '<>', '!='].index(operator))
              raise ArgumentError.new("Invalid attribute '#{attr}' in filter")
            end
  
-           case operator.downcase
+           case operator
            when '=', '<', '<=', '>', '>=', '!=', 'like', 'ilike'
              attr_type = attr_model_class.attribute_column(attr).type
              operator = '<>' if operator == '!='
                end
              end
              cond_out << cond.join(' OR ')
+           when 'contains'
+             if col.andand.type != :jsonb
+               raise ArgumentError.new("Invalid attribute '#{attr}' for '#{operator}' operator")
+             end
+             if operand == []
+               raise ArgumentError.new("Invalid operand '#{operand.inspect}' for '#{operator}' operator")
+             end
+             operand = [operand] unless operand.is_a? Array
+             operand.each do |op|
+               if !op.is_a?(String)
+                 raise ArgumentError.new("Invalid element #{operand.inspect} in operand for #{operator.inspect} operator (operand must be a string or array of strings)")
+               end
+             end
+             # We use jsonb_exists_all(a,b) instead of "a ?& b" because
+             # the pg gem thinks "?" is a bind var. And we use string
+             # interpolation instead of param_out because the pg gem
+             # flattens param_out / doesn't support passing arrays as
+             # bind vars.
+             q = operand.map { |s| ActiveRecord::Base.connection.quote(s) }.join(',')
+             cond_out << "jsonb_exists_all(#{attr_table_name}.#{attr}, array[#{q}])"
            else
              raise ArgumentError.new("Invalid operator '#{operator}'")
            end
index bcb18078674ffd27bb124772b4478ebecfff9a76,04d0329b26caac6ce30f500b091602a727922190..dd8eeaa7bead1e260d46e5da4142707792edd42a
@@@ -29,14 -29,34 +29,14 @@@ class Arvados::V1::FiltersTest < Action
                   json_response['errors'].join(' '))
    end
  
 -  test 'error message for full text search on a specific column' do
 +  test 'error message for unsupported full text search' do
      @controller = Arvados::V1::CollectionsController.new
      authorize_with :active
      get :index, params: {
        filters: [['uuid', '@@', 'abcdef']],
      }
      assert_response 422
 -    assert_match(/not supported/, json_response['errors'].join(' '))
 -  end
 -
 -  test 'difficult characters in full text search' do
 -    @controller = Arvados::V1::CollectionsController.new
 -    authorize_with :active
 -    get :index, params: {
 -      filters: [['any', '@@', 'a|b"c']],
 -    }
 -    assert_response :success
 -    # (Doesn't matter so much which results are returned.)
 -  end
 -
 -  test 'array operand in full text search' do
 -    @controller = Arvados::V1::CollectionsController.new
 -    authorize_with :active
 -    get :index, params: {
 -      filters: [['any', '@@', ['abc', 'def']]],
 -    }
 -    assert_response 422
 -    assert_match(/not supported/, json_response['errors'].join(' '))
 +    assert_match(/no longer supported/, json_response['errors'].join(' '))
    end
  
    test 'api responses provide timestamps with nanoseconds' do
      end
    end
  
 -  test "full text search with count='none'" do
 -    @controller = Arvados::V1::GroupsController.new
 -    authorize_with :admin
 -
 -    get :contents, params: {
 -      format: :json,
 -      count: 'none',
 -      limit: 1000,
 -      filters: [['any', '@@', Rails.configuration.ClusterID]],
 -    }
 -
 -    assert_response :success
 -
 -    all_objects = Hash.new(0)
 -    json_response['items'].map{|o| o['kind']}.each{|t| all_objects[t] += 1}
 -
 -    assert_equal true, all_objects['arvados#group']>0
 -    assert_equal true, all_objects['arvados#job']>0
 -    assert_equal true, all_objects['arvados#pipelineInstance']>0
 -    assert_equal true, all_objects['arvados#pipelineTemplate']>0
 -
 -    # Perform test again mimicking a second page request with:
 -    # last_object_class = PipelineInstance
 -    #   and hence groups and jobs should not be included in the response
 -    # offset = 5, which means first 5 pipeline instances were already received in page 1
 -    #   and hence the remaining pipeline instances and all other object types should be included in the response
 -
 -    @test_counter = 0  # Reset executed action counter
 -
 -    @controller = Arvados::V1::GroupsController.new
 -
 -    get :contents, params: {
 -      format: :json,
 -      count: 'none',
 -      limit: 1000,
 -      offset: '5',
 -      last_object_class: 'PipelineInstance',
 -      filters: [['any', '@@', Rails.configuration.ClusterID]],
 -    }
 -
 -    assert_response :success
 -
 -    second_page = Hash.new(0)
 -    json_response['items'].map{|o| o['kind']}.each{|t| second_page[t] += 1}
 -
 -    assert_equal false, second_page.include?('arvados#group')
 -    assert_equal false, second_page.include?('arvados#job')
 -    assert_equal true, second_page['arvados#pipelineInstance']>0
 -    assert_equal all_objects['arvados#pipelineInstance'], second_page['arvados#pipelineInstance']+5
 -    assert_equal true, second_page['arvados#pipelineTemplate']>0
 -  end
 -
    [['prop1', '=', 'value1', [:collection_with_prop1_value1], [:collection_with_prop1_value2, :collection_with_prop2_1]],
     ['prop1', '!=', 'value1', [:collection_with_prop1_value2, :collection_with_prop2_1], [:collection_with_prop1_value1]],
     ['prop1', 'exists', true, [:collection_with_prop1_value1, :collection_with_prop1_value2, :collection_with_prop1_value3, :collection_with_prop1_other1], [:collection_with_prop2_1]],
      assert_includes(found, collections(:replication_desired_2_unconfirmed).uuid)
      assert_includes(found, collections(:replication_desired_2_confirmed_2).uuid)
    end
+   [
+     [1, "foo"],
+     [1, ["foo"]],
+     [1, ["bar"]],
+     [1, ["bar", "foo"]],
+     [0, ["foo", "qux"]],
+     [0, ["qux"]],
+     [nil, []],
+     [nil, [[]]],
+     [nil, [["bogus"]]],
+     [nil, [{"foo" => "bar"}]],
+     [nil, {"foo" => "bar"}],
+   ].each do |results, operand|
+     test "storage_classes_desired contains #{operand.inspect}" do
+       @controller = Arvados::V1::CollectionsController.new
+       authorize_with(:active)
+       c = Collection.create!(
+         manifest_text: "",
+         storage_classes_desired: ["foo", "bar", "baz"])
+       get :index, params: {
+             filters: [["storage_classes_desired", "contains", operand]],
+           }
+       if results.nil?
+         assert_response 422
+         next
+       end
+       assert_response :success
+       assert_equal results, json_response["items"].length
+       if results > 0
+         assert_equal c.uuid, json_response["items"][0]["uuid"]
+       end
+     end
+   end
+   test "collections properties contains top level key" do
+     @controller = Arvados::V1::CollectionsController.new
+     authorize_with(:active)
+     get :index, params: {
+           filters: [["properties", "contains", "prop1"]],
+         }
+     assert_response :success
+     assert_not_empty json_response["items"]
+     json_response["items"].each do |c|
+       assert c["properties"].has_key?("prop1")
+     end
+   end
  end