diff --git a/lib/meilisearch-rails.rb b/lib/meilisearch-rails.rb index 1d9a298e..20d727a1 100644 --- a/lib/meilisearch-rails.rb +++ b/lib/meilisearch-rails.rb @@ -3,6 +3,7 @@ require 'meilisearch/rails/version' require 'meilisearch/rails/utilities' require 'meilisearch/rails/errors' +require 'meilisearch/rails/multi_search' if defined? Rails begin @@ -760,6 +761,11 @@ def ms_must_reindex?(document) false end + def ms_primary_key_method(options = nil) + options ||= meilisearch_options + options[:primary_key] || options[:id] || :id + end + protected def ms_ensure_init(options = meilisearch_options, settings = meilisearch_settings, user_configuration = settings.to_settings) @@ -814,11 +820,6 @@ def ms_configurations @configurations end - def ms_primary_key_method(options = nil) - options ||= meilisearch_options - options[:primary_key] || options[:id] || :id - end - def ms_primary_key_of(doc, options = nil) doc.send(ms_primary_key_method(options)).to_s end diff --git a/lib/meilisearch/rails/multi_search.rb b/lib/meilisearch/rails/multi_search.rb new file mode 100644 index 00000000..1f9b63bd --- /dev/null +++ b/lib/meilisearch/rails/multi_search.rb @@ -0,0 +1,49 @@ +require_relative 'multi_search/result' + +module MeiliSearch + module Rails + class << self + def multi_search(searches) + search_parameters = searches.map do |(index_target, options)| + paginate(options) if pagination_enabled? + normalize(options, index_target) + end + + MultiSearchResult.new(searches, client.multi_search(search_parameters)) + end + + private + + def normalize(options, index_target) + options + .except(:class_name) + .merge!(index_uid: index_uid_from_target(index_target)) + end + + def index_uid_from_target(index_target) + case index_target + when String, Symbol + index_target + else + index_target.index.uid + end + end + + def paginate(options) + %w[page hitsPerPage hits_per_page].each do |key| + # Deletes hitsPerPage to avoid passing along a meilisearch-ruby warning/exception + value = options.delete(key) || options.delete(key.to_sym) + options[key.underscore.to_sym] = value.to_i if value + end + + # It is required to activate the finite pagination in Meilisearch v0.30 (or newer), + # to have at least `hits_per_page` defined or `page` in the search request. + options[:page] ||= 1 + end + + def pagination_enabled? + MeiliSearch::Rails.configuration[:pagination_backend] + end + end + end +end diff --git a/lib/meilisearch/rails/multi_search/result.rb b/lib/meilisearch/rails/multi_search/result.rb new file mode 100644 index 00000000..593856ab --- /dev/null +++ b/lib/meilisearch/rails/multi_search/result.rb @@ -0,0 +1,84 @@ +module MeiliSearch + module Rails + class MultiSearchResult + attr_reader :metadata + + def initialize(searches, raw_results) + @results = {} + @metadata = {} + + searches.zip(raw_results['results']).each do |(index_target, search_options), result| + index_target = search_options[:class_name].constantize if search_options[:class_name] + + @results[index_target] = case index_target + when String, Symbol + result['hits'] + else + load_results(index_target, result) + end + + @metadata[index_target] = result.except('hits') + end + end + + include Enumerable + + def each_hit(&block) + @results.each do |_index_target, results| + results.each(&block) + end + end + alias each each_hit + + def each_result + @results.each + end + + def to_a + @results.values.flatten(1) + end + alias to_ary to_a + + def to_h + @results + end + alias to_hash to_h + + private + + def load_results(klass, result) + pk_method = klass.ms_primary_key_method + pk_method = pk_method.in if Utilities.mongo_model?(klass) + + condition_key = pk_is_virtual?(klass, pk_method) ? klass.primary_key : pk_method + + hits_by_id = + result['hits'].index_by { |hit| hit[condition_key.to_s] } + + records = klass.where(condition_key => hits_by_id.keys) + + if records.respond_to? :in_order_of + records.in_order_of(condition_key, hits_by_id.keys).each do |record| + record.formatted = hits_by_id[record.send(condition_key).to_s]['_formatted'] + end + else + results_by_id = records.index_by do |hit| + hit.send(condition_key).to_s + end + + result['hits'].filter_map do |hit| + record = results_by_id[hit[condition_key.to_s].to_s] + record&.formatted = hit['_formatted'] + record + end + end + end + + def pk_is_virtual?(model_class, pk_method) + model_class.columns + .map(&(Utilities.sequel_model?(model_class) ? :to_s : :name)) + .exclude?(pk_method.to_s) + end + end + end +end diff --git a/lib/meilisearch/rails/utilities.rb b/lib/meilisearch/rails/utilities.rb index 377eca3d..bd918b7b 100644 --- a/lib/meilisearch/rails/utilities.rb +++ b/lib/meilisearch/rails/utilities.rb @@ -48,6 +48,14 @@ def indexable?(record, options) true end + def mongo_model?(model_class) + defined?(::Mongoid::Document) && model_class.include?(::Mongoid::Document) + end + + def sequel_model?(model_class) + defined?(::Sequel::Model) && model_class < Sequel::Model + end + private def constraint_passes?(record, constraint) diff --git a/spec/multi_search/result_spec.rb b/spec/multi_search/result_spec.rb new file mode 100644 index 00000000..99c35a0f --- /dev/null +++ b/spec/multi_search/result_spec.rb @@ -0,0 +1,118 @@ +require 'spec_helper' + +describe MeiliSearch::Rails::MultiSearchResult do # rubocop:todo RSpec/FilePath + let(:raw_results) do + { + 'results' => [ + { 'indexUid' => 'books_index', + 'hits' => [{ 'name' => 'Steve Jobs', 'id' => '3', 'author' => 'Walter Isaacson', 'premium' => nil, 'released' => nil, 'genre' => nil }], + 'query' => 'Steve', 'processingTimeMs' => 0, 'limit' => 20, 'offset' => 0, 'estimatedTotalHits' => 1 }, + { 'indexUid' => 'products_index', + 'hits' => [{ 'id' => '4', 'href' => 'ebay', 'name' => 'palm pixi plus' }], + 'query' => 'palm', 'processingTimeMs' => 0, 'limit' => 1, 'offset' => 0, 'estimatedTotalHits' => 2 }, + { 'indexUid' => 'color_index', + 'hits' => [ + { 'name' => 'black', 'id' => '5', 'short_name' => 'bla', 'hex' => 0 }, + { 'name' => 'blue', 'id' => '4', 'short_name' => 'blu', 'hex' => 255 } + ], + 'query' => 'bl', 'processingTimeMs' => 0, 'limit' => 20, 'offset' => 0, 'estimatedTotalHits' => 2 } + ] + } + end + + it 'is enumerable' do + expect(described_class).to include(Enumerable) + end + + context 'with index name keys' do + subject(:result) { described_class.new(searches, raw_results) } + + let(:searches) do + { + 'books_index' => { q: 'Steve' }, + 'products_index' => { q: 'palm', limit: 1 }, + 'color_index' => { q: 'bl' } + } + end + + it 'enumerates through the hits' do + expect(result).to contain_exactly( + a_hash_including('author' => 'Walter Isaacson', 'name' => 'Steve Jobs'), + a_hash_including('name' => 'palm pixi plus'), + a_hash_including('name' => 'blue', 'short_name' => 'blu'), + a_hash_including('name' => 'black', 'short_name' => 'bla') + ) + end + + it 'enumerates through the hits of each result with #each_result' do + expect(result.each_result).to be_an(Enumerator) + expect(result.each_result).to contain_exactly( + ['books_index', contain_exactly( + a_hash_including('author' => 'Walter Isaacson', 'name' => 'Steve Jobs') + )], + ['products_index', contain_exactly( + a_hash_including('name' => 'palm pixi plus') + )], + ['color_index', contain_exactly( + a_hash_including('name' => 'blue', 'short_name' => 'blu'), + a_hash_including('name' => 'black', 'short_name' => 'bla') + )] + ) + end + + describe '#to_a' do + it 'returns the hits' do + expect(result.to_a).to contain_exactly( + a_hash_including('author' => 'Walter Isaacson', 'name' => 'Steve Jobs'), + a_hash_including('name' => 'palm pixi plus'), + a_hash_including('name' => 'blue', 'short_name' => 'blu'), + a_hash_including('name' => 'black', 'short_name' => 'bla') + ) + end + + it 'aliases as #to_ary' do + expect(result.method(:to_ary).original_name).to eq :to_a + end + end + + describe '#to_h' do + it 'returns a hash of indexes and hits' do + expect(result.to_h).to match( + 'books_index' => contain_exactly( + a_hash_including('author' => 'Walter Isaacson', 'name' => 'Steve Jobs') + ), + 'products_index' => contain_exactly( + a_hash_including('name' => 'palm pixi plus') + ), + 'color_index' => contain_exactly( + a_hash_including('name' => 'blue', 'short_name' => 'blu'), + a_hash_including('name' => 'black', 'short_name' => 'bla') + ) + ) + end + + it 'is aliased as #to_hash' do + expect(result.method(:to_hash).original_name).to eq :to_h + end + end + + describe '#metadata' do + it 'returns search metadata for each result' do + expect(result.metadata).to match( + 'books_index' => { + 'indexUid' => 'books_index', + 'query' => 'Steve', 'processingTimeMs' => 0, 'limit' => 20, 'offset' => 0, 'estimatedTotalHits' => 1 + }, + 'products_index' => { + 'indexUid' => 'products_index', + 'query' => 'palm', 'processingTimeMs' => 0, 'limit' => 1, 'offset' => 0, 'estimatedTotalHits' => 2 + }, + 'color_index' => { + 'indexUid' => 'color_index', + 'query' => 'bl', 'processingTimeMs' => 0, 'limit' => 20, 'offset' => 0, 'estimatedTotalHits' => 2 + } + ) + end + end + end +end diff --git a/spec/multi_search_spec.rb b/spec/multi_search_spec.rb new file mode 100644 index 00000000..c8f9ed17 --- /dev/null +++ b/spec/multi_search_spec.rb @@ -0,0 +1,121 @@ +require 'spec_helper' + +describe 'multi-search' do # rubocop:todo RSpec/DescribeClass + def reset_indexes + [Book, Color, Product].each do |klass| + klass.delete_all + klass.clear_index!(true) + end + end + + before do + reset_indexes + + Product.create! name: 'palm pixi plus', href: 'ebay', tags: ['terrible'] + Product.create! name: 'lg vortex', href: 'ebay', tags: ['decent'] + Product.create! name: 'palmpre', href: 'ebay', tags: ['discontinued', 'worst phone ever'] + Product.reindex! + + Color.create! name: 'blue', short_name: 'blu', hex: 0x0000FF + Color.create! name: 'black', short_name: 'bla', hex: 0x000000 + Color.create! name: 'green', short_name: 'gre', hex: 0x00FF00 + + Book.create! name: 'Steve Jobs', author: 'Walter Isaacson' + Book.create! name: 'Moby Dick', author: 'Herman Melville' + end + + let!(:palm_pixi_plus) { Product.find_by name: 'palm pixi plus' } + let!(:steve_jobs) { Book.find_by name: 'Steve Jobs' } + let!(:blue) { Color.find_by name: 'blue' } + let!(:black) { Color.find_by name: 'black' } + + context 'with class keys' do + it 'returns ORM records' do + results = MeiliSearch::Rails.multi_search( + Book => { q: 'Steve' }, + Product => { q: 'palm', limit: 1 }, + Color => { q: 'bl' } + ) + + expect(results).to contain_exactly( + steve_jobs, palm_pixi_plus, blue, black + ) + end + end + + context 'with index name keys' do + it 'returns hashes' do + results = MeiliSearch::Rails.multi_search( + Book.index.uid => { q: 'Steve' }, + Product.index.uid.to_sym => { q: 'palm', limit: 1 }, + Color.index.uid => { q: 'bl' } + ) + + expect(results).to contain_exactly( + a_hash_including('author' => 'Walter Isaacson', 'name' => 'Steve Jobs'), + a_hash_including('name' => 'palm pixi plus'), + a_hash_including('name' => 'blue', 'short_name' => 'blu'), + a_hash_including('name' => 'black', 'short_name' => 'bla') + ) + end + + context 'when class_name is specified' do + it 'returns ORM records' do + results = MeiliSearch::Rails.multi_search( + Book.index.uid => { q: 'Steve', class_name: 'Book' }, + Product.index.uid.to_sym => { q: 'palm', limit: 1, class_name: 'Product' }, + Color.index.uid => { q: 'bl', class_name: 'Color' } + ) + + expect(results).to contain_exactly( + steve_jobs, palm_pixi_plus, blue, black + ) + end + + it 'throws error if class cannot be found' do + expect do + MeiliSearch::Rails.multi_search( + Book.index.uid => { q: 'Steve', class_name: 'Book' }, + Product.index.uid.to_sym => { q: 'palm', limit: 1, class_name: 'ProductOfCapitalism' }, + Color.index.uid => { q: 'bl', class_name: 'Color' } + ) + end.to raise_error(NameError) + end + end + end + + context 'with a mixture of symbol and class keys' do + it 'returns a mixture of ORM records and hashes' do + results = MeiliSearch::Rails.multi_search( + Book => { q: 'Steve' }, + Product.index.uid => { q: 'palm', limit: 1, class_name: 'Product' }, + Color.index.uid => { q: 'bl' } + ) + + expect(results).to contain_exactly( + steve_jobs, palm_pixi_plus, + a_hash_including('name' => 'blue', 'short_name' => 'blu'), + a_hash_including('name' => 'black', 'short_name' => 'bla') + ) + end + end + + context 'with pagination' do + it 'properly paginates each search' do + MeiliSearch::Rails.configuration[:pagination_backend] = :kaminari + + results = MeiliSearch::Rails.multi_search( + Book => { q: 'Steve' }, + Product => { q: 'palm', page: 1, hits_per_page: 1 }, + Color.index.uid => { q: 'bl', page: 1, 'hitsPerPage' => '1' } + ) + + expect(results).to contain_exactly( + steve_jobs, palm_pixi_plus, + a_hash_including('name' => 'black', 'short_name' => 'bla') + ) + + MeiliSearch::Rails.configuration[:pagination_backend] = nil + end + end +end