diff --git a/app/controllers/api/resource_controller.rb b/app/controllers/api/resource_controller.rb index 69edffe5c..ce5a06ff9 100644 --- a/app/controllers/api/resource_controller.rb +++ b/app/controllers/api/resource_controller.rb @@ -5,7 +5,11 @@ class Api::ResourceController < Api::BaseController before_action :validate_payload, only: [:create, :update] def index - render json: ApiResourceSerializer.new(@api_namespace.api_resources.order(updated_at: :desc)).serializable_hash + response = @api_namespace.api_resources + + response = response.jsonb_search(:properties, search_params.to_hash) if params[:properties] + + render json: ApiResourceSerializer.new(response.order(updated_at: :desc)).serializable_hash end def query @@ -83,4 +87,8 @@ def load_api_resource def resource_params params.permit(data: {}) end + + def search_params + params.require(:properties).permit! + end end diff --git a/app/graphql/types/api_namespace_type.rb b/app/graphql/types/api_namespace_type.rb index d53a7990f..69a9a70ad 100755 --- a/app/graphql/types/api_namespace_type.rb +++ b/app/graphql/types/api_namespace_type.rb @@ -24,6 +24,7 @@ class ApiNamespaceType < Types::BaseObject argument :order_direction, String, required: false argument :order_dimension, String, required: false argument :offset, Integer, required: false + argument :properties, GraphQL::Types::JSON, required: false def resolve(parent, frozen_parameters, context) parameters = { **frozen_parameters } @@ -31,7 +32,11 @@ def resolve(parent, frozen_parameters, context) parameters[:order_direction] ||= 'desc' parameters[:limit] ||= 50 parameters[:offset] ||= 0 - parent.object.api_resources.order("#{parameters[:order_dimension].underscore} #{parameters[:order_direction]}").limit(parameters[:limit]).offset(parameters[:offset]) + + api_resources = parent.object.api_resources + api_resources = api_resources.jsonb_search(:properties, parameters[:properties]) if parameters[:properties] + + api_resources.order("#{parameters[:order_dimension].underscore} #{parameters[:order_direction]}").limit(parameters[:limit]).offset(parameters[:offset]) end end end diff --git a/app/models/api_resource.rb b/app/models/api_resource.rb index c038107a4..3019a3e13 100755 --- a/app/models/api_resource.rb +++ b/app/models/api_resource.rb @@ -1,5 +1,6 @@ class ApiResource < ApplicationRecord include JsonbFieldsParsable + include JsonbSearch::Searchable after_initialize :inherit_properties_from_parent diff --git a/app/models/concerns/jsonb_search/query_builder.rb b/app/models/concerns/jsonb_search/query_builder.rb new file mode 100644 index 000000000..0c117b56b --- /dev/null +++ b/app/models/concerns/jsonb_search/query_builder.rb @@ -0,0 +1,98 @@ +module JsonbSearch + module QueryBuilder + QUERY_OPTION = { + EXACT: 'EXACT', + PARTIAL: 'PARTIAL' + }.freeze + + class << self + def build_jsonb_query(column_name, query_params) + parsed_params = parse_params(query_params.deep_symbolize_keys) + build(parsed_params, column_name) + end + + private + + def parse_params(query_params) + queries = [] + + query_params.each do |key, value| + if value.is_a?(Hash) && value.key?(:value) + # { name: { value: 'violet', query }} || { name: { value: 'violet', option: 'EXACT' }} + queries << { option: value[:option] || QUERY_OPTION[:EXACT], key: key, value: value[:value] } + elsif value.is_a?(Hash) + # { foo: { bar: 'baz', wat: 'up' }} + value.each do |k, v| + if v.is_a?(Hash) && v.key?(:value) + queries << { key: key, value: [{ option: v[:option] || QUERY_OPTION[:EXACT], key: k, value: v[:value] }] } + else + queries << { key: key, value: [{ option: QUERY_OPTION[:EXACT], key: k, value: v }]} + end + end + else + # { name: 'violet' } || { tags: ['violet', 'rails'] } + queries << { option: QUERY_OPTION[:EXACT], key: key, value: value } + end + end + + queries + end + + def build(queries, column_name) + queries_array = queries.map do |object| + generate_query(object, column_name) + end + queries_array.join(' AND ') + end + + def generate_query(param, query_string) + key = param[:key] + term = param[:value] + option = param[:option] + case term.class.to_s + + when 'Hash' + return hash_query(key, term, option, query_string) + when 'Array' + if option + return array_query(key, term, option, query_string) + else + term.each do |obj| + # "column -> 'property' ->> 'nested property' = 'term'" + query_string = generate_query(obj, "#{query_string} -> '#{key}'") + end + + return query_string + end + else + return string_query(key, term, option, query_string) + end + end + + # "column ->> 'property' = 'term'" + def string_query(key, term, option, query) + if option == QUERY_OPTION[:PARTIAL] + term = "%#{term}%" + operator = 'LIKE' + end + + "lower(#{query} ->> '#{key}') #{operator || '='} lower('#{term}')" + end + + # "column -> 'property' @> '{/"search/": /"term/"}'" + def hash_query(key, term, option, query) + operator = option == QUERY_OPTION[:PARTIAL] ? '@>' : '=' + "#{query} -> '#{key}' #{operator} '#{term.to_json}'" + end + + # "column -> 'property' ? '['term']'" + def array_query(key, term, option, query) + if option == QUERY_OPTION[:PARTIAL] + "#{query} -> '#{key}' @> '#{term.to_json}'" + else + "#{query} -> '#{key}' @> '#{term.to_json}' AND #{query} -> '#{key}' <@ '#{term.to_json}'" + end + end + end + end +end \ No newline at end of file diff --git a/app/models/concerns/jsonb_search/searchable.rb b/app/models/concerns/jsonb_search/searchable.rb new file mode 100644 index 000000000..f1425fe4a --- /dev/null +++ b/app/models/concerns/jsonb_search/searchable.rb @@ -0,0 +1,14 @@ +# Search Jsonb fields +# Simple query format => { name: 'violet' } +# Extended format => { name: { value: 'violet', option: 'PARTIAL' } }, option is optional, default is EXACT + +module JsonbSearch + module Searchable + extend ActiveSupport::Concern + include JsonbSearch::QueryBuilder + + included do + scope :jsonb_search, ->(column_name, query) { where(JsonbSearch::QueryBuilder.build_jsonb_query(column_name, query)) } + end + end +end \ No newline at end of file diff --git a/test/controllers/api/resource_controller_test.rb b/test/controllers/api/resource_controller_test.rb index 1b1dd4e14..ed75a44a3 100644 --- a/test/controllers/api/resource_controller_test.rb +++ b/test/controllers/api/resource_controller_test.rb @@ -4,7 +4,29 @@ class Api::ResourceControllerTest < ActionDispatch::IntegrationTest setup do @api_namespace = api_namespaces(:one) @users_namespace = api_namespaces(:users) + + @api_resource_1 = ApiResource.create(api_namespace_id: @api_namespace.id, properties: { + name: 'John Doe', + age: 35, + interests: ['software', 'web', 'games'], + object: { foo: 'bar', baz: { a: 'b' } } + }) + + @api_resource_2 = ApiResource.create(api_namespace_id: @api_namespace.id, properties: { + name: 'Jack D', + age: 90, + interests: ['movies'], + object: { x: 'y', z: 'a'} + }) + + @api_resource_3 = ApiResource.create(api_namespace_id: @api_namespace.id, properties: { + name: 'John Cena', + age: 50, + interests: ['random', 'text'], + object: {} + }) end + test 'describe resource name and version: get #index as json' do get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), as: :json assert_response :success @@ -173,4 +195,273 @@ class Api::ResourceControllerTest < ActionDispatch::IntegrationTest delete api_destroy_resource_url(version: @users_namespace.version, api_namespace: @users_namespace.slug, api_resource_id: 42), headers: { 'Authorization': "Bearer #{api_client.bearer_token}" } assert_equal response.parsed_body["code"], 404 end + + test '#index search jsonb field - string - simple query - exact' do + payload = { + properties: { + name: @api_resource_1.properties['name'] + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + assert_response :success + + assert_equal response.parsed_body["data"].pluck("id"), [@api_resource_1.id.to_s] + end + + test '#index search jsonb field - string - simple query - exact (unhappy)' do + payload = { + properties: { + name: @api_resource_1.properties['name'].split(' ')[0] + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + assert_response :success + + assert_empty response.parsed_body["data"] + end + + test '#index search jsonb field - string - extened query - exact' do + payload = { + properties: { + name: { + value: @api_resource_1.properties['name'], + option: 'EXACT' + } + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + assert_response :success + + assert_equal response.parsed_body["data"].pluck("id"), [@api_resource_1.id.to_s] + end + + test '#index search jsonb field - string - extened query - exact - case insensitive' do + payload = { + properties: { + name: { + value: @api_resource_1.properties['name'].downcase, + option: 'EXACT' + } + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + assert_response :success + + assert_equal response.parsed_body["data"].pluck("id"), [@api_resource_1.id.to_s] + end + + test '#index search jsonb field - string - partial' do + payload = { + properties: { + name: { + value: 'john', + option: 'PARTIAL' + } + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + assert_response :success + + assert_equal response.parsed_body["data"].pluck("id").map(&:to_i).sort, [@api_resource_1.id, @api_resource_3.id].sort + end + + test '#index search jsonb field - string - partial (unhappy)' do + payload = { + properties: { + name: { + value: 'not a name', + option: 'PARTIAL' + } + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + assert_response :success + + assert_empty response.parsed_body["data"] + end + + test '#index search jsonb field - nested string' do + payload = { + properties: { + object: { + foo: 'bar' + } + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + assert_response :success + + assert_equal response.parsed_body["data"].pluck("id").map(&:to_i).sort, [@api_resource_1.id].sort + + payload = { + properties: { + object: { + x: 'y', + z: 'a' + } + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + assert_response :success + + assert_equal response.parsed_body["data"].pluck("id").map(&:to_i).sort, [@api_resource_2.id].sort + end + + test '#index search jsonb field - nested string (two level) - partial' do + payload = { + properties: { + object: { + baz: { + a: 'b' + } + } + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + assert_response :success + + assert_equal response.parsed_body["data"].pluck("id").map(&:to_i).sort, [@api_resource_1.id].sort + end + + test '#index search jsonb field - integer' do + payload = { + properties: { + age: 35 + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + assert_response :success + + assert_equal response.parsed_body["data"].pluck("id").map(&:to_i).sort, [@api_resource_1.id].sort + end + + test '#index search jsonb field - integer (unhappy path)' do + payload = { + properties: { + age: 800 + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + assert_response :success + + assert_empty response.parsed_body["data"] + end + + test '#index search jsonb field - hash - exact match' do + payload = { + properties: { + object: { + value: { x: 'y', z: 'a'}, + option: 'EXACT' + } + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + assert_response :success + + assert_equal response.parsed_body["data"].pluck("id").map(&:to_i).sort, [@api_resource_2.id].sort + end + + test '#index search jsonb field - hash - exact match(unhappy path)' do + payload = { + properties: { + object: { + value: { x: 'y'}, + option: 'EXACT' + } + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + + assert_response :success + assert_empty response.parsed_body["data"] + end + + test '#index search jsonb field - hash - partial match' do + payload = { + properties: { + object: { + value: { x: 'y' }, + option: 'PARTIAL' + } + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + assert_response :success + + assert_equal response.parsed_body["data"].pluck("id").map(&:to_i).sort, [@api_resource_2.id].sort + end + + test '#index search jsonb field - array - exact match' do + payload = { + properties: { + interests: ['software', 'web', 'games'] + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + + assert_equal response.parsed_body["data"].pluck("id").map(&:to_i).sort, [@api_resource_1.id].sort + + # array match should be independent of order + payload = { + properties: { + interests: [ 'web', 'software', 'games'] + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + + assert_equal response.parsed_body["data"].pluck("id").map(&:to_i).sort, [@api_resource_1.id].sort + + # extended query + payload = { + properties: { + interests: { + value:[ 'web', 'software', 'games'], + option: 'EXACT' + } + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + + assert_equal response.parsed_body["data"].pluck("id").map(&:to_i).sort, [@api_resource_1.id].sort + end + + test '#index search jsonb field - array - exact match (unhappy)' do + payload = { + properties: { + interests: [ 'web', 'software'] + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + + assert_empty response.parsed_body["data"] + end + + test '#index search jsonb field - array - partial match' do + payload = { + properties: { + interests: { + value: [ 'web', 'software'], + option: 'PARTIAL' + } + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + + assert_equal response.parsed_body["data"].pluck("id").map(&:to_i).sort, [@api_resource_1.id].sort + end + + test '#index search jsonb field - array - partial match (unhappy)' do + payload = { + properties: { + interests: { + value: [ 'web', 'not a member'], + option: 'PARTIAL' + } + } + } + get api_url(version: @api_namespace.version, api_namespace: @api_namespace.slug), params: payload, as: :json + + assert_empty response.parsed_body["data"] + end end diff --git a/test/controllers/graphql_controller_test.rb b/test/controllers/graphql_controller_test.rb index f2705b969..022a6f8f0 100644 --- a/test/controllers/graphql_controller_test.rb +++ b/test/controllers/graphql_controller_test.rb @@ -64,6 +64,13 @@ class GraphqlControllerTest < ActionDispatch::IntegrationTest assert_equal ["data"], json_response.keys end + test "[if enabled] query public API Namespaces with nested apiResources with searching by properties" do + query_string = "{ apiNamespaces { id apiResources(properties: { name: { value: \"violet\", option: \"PARTIAL\" } }) { id } } }" + post '/graphql', params: { query: query_string } + json_response = JSON.parse(@response.body) + assert_equal ["data"], json_response.keys + end + test "[if enabled] && [if subdomain allows analytics query via API] allows ahoy visit query" do @subdomain.update(allow_external_analytics_query: true) get root_url diff --git a/test/models/concerns/jsonb_search/query_builder_test.rb b/test/models/concerns/jsonb_search/query_builder_test.rb new file mode 100644 index 000000000..9f8749de7 --- /dev/null +++ b/test/models/concerns/jsonb_search/query_builder_test.rb @@ -0,0 +1,80 @@ +require "test_helper" + +class JsonbSearch::QueryBuilderTest < ActiveSupport::TestCase + test 'query string' do + query = { name: 'violet' } + jsonb_query = JsonbSearch::QueryBuilder.build_jsonb_query(:properties, query) + + assert_equal jsonb_query, "lower(properties ->> 'name') = lower('violet')" + end + + test 'query string - extended format' do + query = { name: { value: 'violet', option: 'EXACT' } } + jsonb_query = JsonbSearch::QueryBuilder.build_jsonb_query(:properties, query) + + assert_equal jsonb_query, "lower(properties ->> 'name') = lower('violet')" + end + + test 'query string - partial' do + query = { name: { value: 'violet', option: 'PARTIAL' } } + jsonb_query = JsonbSearch::QueryBuilder.build_jsonb_query(:properties, query) + + assert_equal jsonb_query, "lower(properties ->> 'name') LIKE lower('%violet%')" + end + + test 'query string - multiple properties' do + query = { name: { value: 'violet' }, age: 20 } + jsonb_query = JsonbSearch::QueryBuilder.build_jsonb_query(:properties, query) + + assert_equal jsonb_query, "lower(properties ->> 'name') = lower('violet') AND lower(properties ->> 'age') = lower('20')" + end + + test 'query string - nested' do + query = { foo: { bar: 'baz' } } + jsonb_query = JsonbSearch::QueryBuilder.build_jsonb_query(:properties, query) + + assert_equal jsonb_query, "lower(properties -> 'foo' ->> 'bar') = lower('baz')" + end + + test 'query json - exact' do + query = { object: { value: { foo: 'bar' }, option: 'EXACT'} } + jsonb_query = JsonbSearch::QueryBuilder.build_jsonb_query(:properties, query) + + assert_equal jsonb_query, "properties -> 'object' = '#{query[:object][:value].to_json}'" + end + + test 'query json - partial' do + query = { object: { value: { foo: 'bar' }, option: 'PARTIAL'} } + jsonb_query = JsonbSearch::QueryBuilder.build_jsonb_query(:properties, query) + + assert_equal jsonb_query, "properties -> 'object' @> '#{query[:object][:value].to_json}'" + end + + test 'query array - exact' do + query = { array: ['foo', 'bar'] } + jsonb_query = JsonbSearch::QueryBuilder.build_jsonb_query(:properties, query) + + assert_equal jsonb_query, "properties -> 'array' @> '#{query[:array].to_json}' AND properties -> 'array' <@ '#{query[:array].to_json}'" + end + + test 'query array - partial' do + query = { array: { value: ['foo', 'bar'], option: 'PARTIAL' } } + jsonb_query = JsonbSearch::QueryBuilder.build_jsonb_query(:properties, query) + + assert_equal jsonb_query, "properties -> 'array' @> '#{query[:array][:value].to_json}'" + end + + test 'query array - nested' do + + query = { foo: { array: ['foo', 'bar'] } } + jsonb_query = JsonbSearch::QueryBuilder.build_jsonb_query(:properties, query) + + assert_equal jsonb_query, "properties -> 'foo' -> 'array' @> '#{query[:foo][:array].to_json}' AND properties -> 'foo' -> 'array' <@ '#{query[:foo][:array].to_json}'" + + # extended query + query = { foo: { array: { value: ['foo', 'bar'], option: 'PARTIAL' } } } + jsonb_query = JsonbSearch::QueryBuilder.build_jsonb_query(:properties, query) + + assert_equal jsonb_query, "properties -> 'foo' -> 'array' @> '#{query[:foo][:array][:value].to_json}'" + end +end \ No newline at end of file