diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index a0ed496..fcb1d8e 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2018-05-08 14:54:25 +0200 using RuboCop version 0.55.0. +# on 2018-05-08 16:11:28 +0200 using RuboCop version 0.55.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -21,7 +21,7 @@ Layout/EndAlignment: Exclude: - 'lib/mongoid/full_text_search.rb' -# Offense count: 1 +# Offense count: 2 # Configuration parameters: AllowSafeAssignment. Lint/AssignmentInCondition: Exclude: @@ -43,11 +43,11 @@ Lint/UselessAssignment: Exclude: - 'spec/mongoid/full_text_search_spec.rb' -# Offense count: 5 +# Offense count: 7 Metrics/AbcSize: Max: 104 -# Offense count: 7 +# Offense count: 8 # Configuration parameters: CountComments, ExcludedMethods. Metrics/BlockLength: Max: 622 @@ -64,7 +64,7 @@ Metrics/MethodLength: # Offense count: 1 # Configuration parameters: CountComments. Metrics/ModuleLength: - Max: 223 + Max: 243 # Offense count: 4 Metrics/PerceivedComplexity: @@ -129,7 +129,7 @@ Style/NumericPredicate: - 'spec/**/*' - 'lib/mongoid/full_text_search.rb' -# Offense count: 262 +# Offense count: 269 # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. # URISchemes: http, https Metrics/LineLength: diff --git a/CHANGELOG.md b/CHANGELOG.md index 94b073f..15d0013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ### 0.8.3 (Next) +* [#37](https://github.com/mongoid/mongoid_fulltext/pull/37): Sci & criteria support - [@tomasc](https://github.com/tomasc). * Your contribution here. ### 0.8.2 (8/5/2018) diff --git a/README.md b/README.md index 195d417..1791363 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,40 @@ the AND of all of the individual results for each of the fields. Finally, if a f but criteria for that filter aren't passed to `fulltext_search`, the result is as if the filter had never been defined - you see both models that both pass and fail the filter in the results. +SCI Support +----------- + +The search respects SCI. From the spec: + +```ruby +class MyDoc + include Mongoid::Document + include Mongoid::FullTextSearch + + field :title + fulltext_search_in :title +end + +class MyInheritedDoc < MyDoc +end +``` + +```ruby +MyDoc.fulltext_search(…) # => will return both MyDoc as well as MyInheritedDoc documents +MyInheritedDoc.fulltext_search(…) # => will return only MyInheritedDoc documents +``` + +Criteria Support +---------------- + +It is also possible to pre-empt the search with Monogid criteria: + +```ruby +MyDoc.where(value: 10).fulltext_search(…) +``` + +Please note that this will not work in case an index is shared by multiple classes (that are not connected through inheritance), since a criteria applies only to one class. + Indexing Options ---------------- diff --git a/lib/mongoid/full_text_search.rb b/lib/mongoid/full_text_search.rb index cf12703..d2b9d1c 100644 --- a/lib/mongoid/full_text_search.rb +++ b/lib/mongoid/full_text_search.rb @@ -70,10 +70,18 @@ def fulltext_search_in(*args) def create_fulltext_indexes return unless mongoid_fulltext_config mongoid_fulltext_config.each_pair do |index_name, fulltext_config| - fulltext_search_ensure_indexes(index_name, fulltext_config) + ::I18n.available_locales.each do |locale| + fulltext_search_ensure_indexes(localized_index_name(index_name, locale), fulltext_config) + end end end + def localized_index_name(index_name, locale) + return index_name unless fields.values.any?(&:localized?) + return index_name unless ::I18n.available_locales.count > 1 + "#{index_name}_#{locale}" + end + def fulltext_search_ensure_indexes(index_name, config) db = collection.database coll = db[index_name] @@ -131,6 +139,7 @@ def fulltext_search(query_string, options = {}) end index_name = options.key?(:index) ? options.delete(:index) : mongoid_fulltext_config.keys.first + loc_index_name = localized_index_name(index_name, ::I18n.locale) # Options hash should only contain filters after this point ngrams = all_ngrams(query_string, mongoid_fulltext_config[index_name]) @@ -140,9 +149,10 @@ def fulltext_search(query_string, options = {}) # get a count of the number of index documents containing that n-gram ordering = { 'score' => -1 } limit = mongoid_fulltext_config[index_name][:max_candidate_set_size] - coll = collection.database[index_name] + coll = collection.database[loc_index_name] cursors = ngrams.map do |ngram| query = { 'ngram' => ngram[0] } + query.update(document_type_filters) query.update(map_query_filters(options)) count = coll.find(query).count { ngram: ngram, count: count, query: query } @@ -191,7 +201,11 @@ def fulltext_search(query_string, options = {}) end def instantiate_mapreduce_result(result) - result[:clazz].constantize.find(result[:id]) + if criteria.selector.empty? + result[:clazz].constantize.find(result[:id]) + else + criteria.where(_id: result[:id]).first + end end def instantiate_mapreduce_results(results, options) @@ -280,11 +294,13 @@ def all_ngrams(str, config, bound_number_returned = true) def remove_from_ngram_index mongoid_fulltext_config.each_pair do |index_name, _fulltext_config| - coll = collection.database[index_name] - if Mongoid::Compatibility::Version.mongoid5_or_newer? - coll.find('class' => name).delete_many - else - coll.find('class' => name).remove_all + ::I18n.available_locales.each do |locale| + coll = collection.database[localized_index_name(index_name, locale)] + if Mongoid::Compatibility::Version.mongoid5_or_newer? + coll.find('class' => name).delete_many + else + coll.find('class' => name).remove_all + end end end end @@ -295,6 +311,13 @@ def update_ngram_index private + # add filter by type according to SCI classes + def document_type_filters + return {} unless fields['_type'].present? + kls = ([self] + descendants).map(&:to_s) + { 'class' => { '$in' => kls } } + end + # Take a list of filters to be mapped so they can update the query # used upon the fulltext search of the ngrams def map_query_filters(filters) @@ -316,45 +339,58 @@ def format_query_filter(operator, key, value) def update_ngram_index mongoid_fulltext_config.each_pair do |index_name, fulltext_config| - if condition = fulltext_config[:update_if] - case condition - when Symbol then next unless send condition - when String then next unless instance_eval condition - when Proc then next unless condition.call self - else; next + ::I18n.available_locales.each do |locale| + loc_index_name = self.class.localized_index_name(index_name, locale) + + if condition = fulltext_config[:update_if] + case condition + when Symbol then next unless send condition + when String then next unless instance_eval condition + when Proc then next unless condition.call self + else; next + end end - end - # remove existing ngrams from external index - coll = collection.database[index_name.to_sym] - if Mongoid::Compatibility::Version.mongoid5_or_newer? - coll.find('document_id' => _id).delete_many - else - coll.find('document_id' => _id).remove_all - end - # extract ngrams from fields - field_values = fulltext_config[:ngram_fields].map { |field| send(field) } - ngrams = field_values.inject({}) { |accum, item| accum.update(self.class.all_ngrams(item, fulltext_config, false)) } - return if ngrams.empty? - # apply filters, if necessary - filter_values = nil - if fulltext_config.key?(:filters) - filter_values = Hash[fulltext_config[:filters].map do |key, value| - begin - [key, value.call(self)] - rescue StandardError - # Suppress any exceptions caused by filters - end - end.compact] - end - # insert new ngrams in external index - ngrams.each_pair do |ngram, score| - index_document = { 'ngram' => ngram, 'document_id' => _id, 'score' => score, 'class' => self.class.name } - index_document['filter_values'] = filter_values if fulltext_config.key?(:filters) + # remove existing ngrams from external index + coll = collection.database[loc_index_name.to_sym] if Mongoid::Compatibility::Version.mongoid5_or_newer? - coll.insert_one(index_document) + coll.find('document_id' => _id).delete_many else - coll.insert(index_document) + coll.find('document_id' => _id).remove_all + end + # extract ngrams from fields + field_values = fulltext_config[:ngram_fields].map do |field_name| + next send(field_name) if field_name == :to_s + next unless field = self.class.fields[field_name.to_s] + if field.localized? + send("#{field_name}_translations")[locale] + else + send(field_name) + end + end + + ngrams = field_values.inject({}) { |accum, item| accum.update(self.class.all_ngrams(item, fulltext_config, false)) } + return if ngrams.empty? + # apply filters, if necessary + filter_values = nil + if fulltext_config.key?(:filters) + filter_values = Hash[fulltext_config[:filters].map do |key, value| + begin + [key, value.call(self)] + rescue StandardError + # Suppress any exceptions caused by filters + end + end.compact] + end + # insert new ngrams in external index + ngrams.each_pair do |ngram, score| + index_document = { 'ngram' => ngram, 'document_id' => _id, 'document_type' => model_name.to_s, 'score' => score, 'class' => self.class.name } + index_document['filter_values'] = filter_values if fulltext_config.key?(:filters) + if Mongoid::Compatibility::Version.mongoid5_or_newer? + coll.insert_one(index_document) + else + coll.insert(index_document) + end end end end @@ -362,11 +398,13 @@ def update_ngram_index def remove_from_ngram_index mongoid_fulltext_config.each_pair do |index_name, _fulltext_config| - coll = collection.database[index_name] - if Mongoid::Compatibility::Version.mongoid5_or_newer? - coll.find('document_id' => _id).delete_many - else - coll.find('document_id' => _id).remove_all + ::I18n.available_locales.each do |locale| + coll = collection.database[self.class.localized_index_name(index_name, locale)] + if Mongoid::Compatibility::Version.mongoid5_or_newer? + coll.find('document_id' => _id).delete_many + else + coll.find('document_id' => _id).remove_all + end end end end diff --git a/spec/models/my_doc.rb b/spec/models/my_doc.rb new file mode 100644 index 0000000..0a87892 --- /dev/null +++ b/spec/models/my_doc.rb @@ -0,0 +1,9 @@ +class MyDoc + include Mongoid::Document + include Mongoid::FullTextSearch + + field :title + field :value, type: Integer + + fulltext_search_in :title +end diff --git a/spec/models/my_further_inherited_doc.rb b/spec/models/my_further_inherited_doc.rb new file mode 100644 index 0000000..42f14e7 --- /dev/null +++ b/spec/models/my_further_inherited_doc.rb @@ -0,0 +1,4 @@ +require 'models/my_inherited_doc.rb' + +class MyFurtherInheritedDoc < MyInheritedDoc +end diff --git a/spec/models/my_inherited_doc.rb b/spec/models/my_inherited_doc.rb new file mode 100644 index 0000000..3cd0109 --- /dev/null +++ b/spec/models/my_inherited_doc.rb @@ -0,0 +1,2 @@ +class MyInheritedDoc < MyDoc +end diff --git a/spec/models/my_localized_doc.rb b/spec/models/my_localized_doc.rb new file mode 100644 index 0000000..5c6e116 --- /dev/null +++ b/spec/models/my_localized_doc.rb @@ -0,0 +1,8 @@ +class MyLocalizedDoc + include Mongoid::Document + include Mongoid::FullTextSearch + + field :title, localize: true + + fulltext_search_in :title +end diff --git a/spec/mongoid/criteria_search_spec.rb b/spec/mongoid/criteria_search_spec.rb new file mode 100644 index 0000000..2e5b8ff --- /dev/null +++ b/spec/mongoid/criteria_search_spec.rb @@ -0,0 +1,14 @@ + +require 'spec_helper' + +describe Mongoid::FullTextSearch do + context 'Criteria' do + let!(:my_doc_1) { MyDoc.create!(title: 'My Doc 1') } + let!(:my_doc_2) { MyDoc.create!(title: 'My Doc 2', value: 10) } + + let(:result) { MyDoc.where(value: 10).fulltext_search('doc') } + + it { expect(result).not_to include my_doc_1 } + it { expect(result).to include my_doc_2 } + end +end diff --git a/spec/mongoid/localized_fields_spec.rb b/spec/mongoid/localized_fields_spec.rb new file mode 100644 index 0000000..a5398bc --- /dev/null +++ b/spec/mongoid/localized_fields_spec.rb @@ -0,0 +1,29 @@ + +require 'spec_helper' + +describe Mongoid::FullTextSearch do + context 'Localized fields' do + let!(:my_doc) { MyLocalizedDoc.create!(title_translations: { en: 'Title', cs: 'Nazev' }) } + + before(:each) do + @default_locale = ::I18n.locale + ::I18n.locale = locale + end + + after(:each) do + ::I18n.locale = @default_locale + end + + context 'en' do + let(:locale) { :en } + it { expect(MyLocalizedDoc.fulltext_search('title')).to include my_doc } + it { expect(MyLocalizedDoc.fulltext_search('nazev')).not_to include my_doc } + end + + context 'cs' do + let(:locale) { :cs } + it { expect(MyLocalizedDoc.fulltext_search('title')).not_to include my_doc } + it { expect(MyLocalizedDoc.fulltext_search('nazev')).to include my_doc } + end + end +end diff --git a/spec/mongoid/sci_search_spec.rb b/spec/mongoid/sci_search_spec.rb new file mode 100644 index 0000000..2b40e66 --- /dev/null +++ b/spec/mongoid/sci_search_spec.rb @@ -0,0 +1,31 @@ + +require 'spec_helper' + +describe Mongoid::FullTextSearch do + context 'SCI' do + let!(:my_doc) { MyDoc.create!(title: 'My Doc') } + let!(:my_inherited_doc) { MyInheritedDoc.create!(title: 'My Inherited Doc') } + let!(:my_further_inherited_doc) { MyFurtherInheritedDoc.create!(title: 'My Inherited Doc') } + + context 'root class returns results for subclasses' do + let(:result) { MyDoc.fulltext_search('doc') } + it { expect(result).to include my_doc } + it { expect(result).to include my_inherited_doc } + it { expect(result).to include my_further_inherited_doc } + end + + context 'child class does not return superclass' do + let(:result) { MyInheritedDoc.fulltext_search('doc') } + it { expect(result).not_to include my_doc } + it { expect(result).to include my_inherited_doc } + it { expect(result).to include my_further_inherited_doc } + end + + context 'child class does not return superclass' do + let(:result) { MyFurtherInheritedDoc.fulltext_search('doc') } + it { expect(result).not_to include my_doc } + it { expect(result).not_to include my_inherited_doc } + it { expect(result).to include my_further_inherited_doc } + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index a01760d..3d5c94e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -22,6 +22,8 @@ config.connect_to('mongoid_fulltext_test') end +::I18n.available_locales = %i[en cs] + RSpec.configure do |c| c.before :each do DatabaseCleaner.clean