diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 21a1a36..fe707a5 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2026-10-17 03:11:51 UTC using RuboCop version 1.92.0. +# on 2026-10-17 03:25:55 UTC using RuboCop version 1.92.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 @@ -49,7 +49,7 @@ Lint/UselessAssignment: Metrics/AbcSize: Max: 109 -# Offense count: 8 +# Offense count: 9 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. # AllowedMethods: refine Metrics/BlockLength: @@ -63,12 +63,12 @@ Metrics/CyclomaticComplexity: # Offense count: 5 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. Metrics/MethodLength: - Max: 50 + Max: 53 # Offense count: 1 # Configuration parameters: CountComments, CountAsOne. Metrics/ModuleLength: - Max: 246 + Max: 258 # Offense count: 5 # Configuration parameters: AllowedMethods, AllowedPatterns. @@ -153,7 +153,7 @@ Style/SymbolProc: Exclude: - 'spec/models/filtered_artwork.rb' -# Offense count: 22 +# Offense count: 21 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings. # URISchemes: http, https diff --git a/CHANGELOG.md b/CHANGELOG.md index e6f766a..c9bd0a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ * [#59](https://github.com/mongoid/mongoid_fulltext/pull/59): Document and cover class-level indexing timeout options - [@dblock](https://github.com/dblock). * [#52](https://github.com/mongoid/mongoid_fulltext/pull/52): Fix full-text index creation before any documents exist - [@dblock](https://github.com/dblock). * [#58](https://github.com/mongoid/mongoid_fulltext/pull/58): Support localized fields with locale-tagged ngrams in one index collection; existing localized indexes must be rebuilt - [@tomasc](https://github.com/tomasc), [@dblock](https://github.com/dblock). +* [#57](https://github.com/mongoid/mongoid_fulltext/pull/57): Respect criteria and single-collection inheritance, skip dangling references, and apply result limits after filtering - [@tomasc](https://github.com/tomasc), [@dblock](https://github.com/dblock). * [#51](https://github.com/mongoid/mongoid_fulltext/pull/51): Modernize CI through Ruby 4.0, Mongoid 9, and MongoDB 8 with real coverage, RuboCop 1.92, and fork-safe Danger workflows - [@dblock](https://github.com/dblock). * [#45](https://github.com/mongoid/mongoid_fulltext/pull/45): Allow for class level ngram updates to not timeout - [@roykolak](https://github.com/roykolak). * Your contribution here. diff --git a/README.md b/README.md index 4bb8ed3..ec7f523 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,10 @@ Localized Mongoid fields (`field :title, type: String, localize: true`) are inde All translations use the original full-text index collection. Each ngram record for an index that explicitly includes a localized field has a string `locale` value, for example `{ document_id: id, locale: 'cs', ngram: 'obr', score: score, class: 'Artwork' }`. Searches filter by locale, backed by a compound index beginning with `locale`, `ngram`, and `score`. Nonlocalized index records remain unchanged. Models sharing a localized index must all include a localized field in that index and use the same locale configuration. Define available locales before indexing and rebuild after adding a locale. The alphabet must still contain the characters being indexed. Existing localized-field users must follow [UPGRADING](UPGRADING.md) to rebuild locale-tagged records. +Searches can be prefixed with Mongoid criteria, for example `Artwork.where(published: true).fulltext_search('flowers', max_results: 5)`. The result remains an array in relevance order, not a chainable criteria; ordering and pagination on the preceding criteria are not a substitute for sorting or paginating that array. Excluded or deleted documents are skipped before applying `max_results`, within the existing candidate-set bound. + +Single-collection inheritance is respected: a root-class search includes its descendants, while a subclass search excludes its parent and siblings. An unscoped search of an index shared by unrelated models still returns all those models. Adding criteria restricts the results to the calling model and its descendants. Existing index records already contain the class name, so no reindex is required. + Additional indexing/query options can be used as parameters to `fulltext_search_in`. * `alphabet`: letters to index, default is `abcdefghijklmnopqrstuvwxyz0123456789 `. diff --git a/lib/mongoid/full_text_search.rb b/lib/mongoid/full_text_search.rb index e9b1de9..4d75496 100644 --- a/lib/mongoid/full_text_search.rb +++ b/lib/mongoid/full_text_search.rb @@ -170,6 +170,7 @@ def fulltext_search(query_string, options = {}) cursors = ngrams.map do |ngram| query = { 'ngram' => ngram[0] } query['locale'] = I18n.locale.to_s if localized_fulltext_index?(config) + query.update(document_type_filters) query.update(map_query_filters(options)) count = coll.find(query).count { ngram: ngram, count: count, query: query } @@ -215,18 +216,26 @@ def fulltext_search(query_string, options = {}) all_scores.concat(scores) end all_scores.sort! { |document1, document2| -document1[:score] <=> -document2[:score] } - instantiate_mapreduce_results(all_scores[0..max_results - 1], return_scores: return_scores) + if max_results > 0 + instantiate_mapreduce_results(all_scores, return_scores: return_scores, max_results: max_results) + else + instantiate_mapreduce_results(all_scores[0..max_results - 1], return_scores: return_scores) + end end def instantiate_mapreduce_result(result) - result[:clazz].constantize.find(result[:id]) + scope = criteria + scope = result[:clazz].constantize.criteria if scope.selector.empty? + scope.where(_id: result[:id]).first end def instantiate_mapreduce_results(results, options) - if options[:return_scores] - results.map { |result| [instantiate_mapreduce_result(result), result[:score]] }.find_all { |result| !result[0].nil? } - else - results.map { |result| instantiate_mapreduce_result(result) }.compact + results.each_with_object([]) do |result, documents| + document = instantiate_mapreduce_result(result) + next unless document + + documents << (options[:return_scores] ? [document, result[:score]] : document) + break documents if options[:max_results] && documents.length >= options[:max_results] end end @@ -326,6 +335,12 @@ def update_ngram_index(options = { timeout: true }) private + def document_type_filters + return {} if criteria.selector.empty? && descendants.empty? && !superclass.respond_to?(:mongoid_fulltext_config) + + { 'class' => { '$in' => ([self] + descendants).map(&:name) } } + 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) diff --git a/spec/models/scoped_artwork.rb b/spec/models/scoped_artwork.rb new file mode 100644 index 0000000..69b7adb --- /dev/null +++ b/spec/models/scoped_artwork.rb @@ -0,0 +1,14 @@ +class ScopedArtwork + include Mongoid::Document + include Mongoid::FullTextSearch + + field :title, type: String + field :value, type: Integer + fulltext_search_in :title +end + +class InheritedArtwork < ScopedArtwork +end + +class FurtherInheritedArtwork < InheritedArtwork +end diff --git a/spec/mongoid/criteria_search_spec.rb b/spec/mongoid/criteria_search_spec.rb new file mode 100644 index 0000000..f7156fa --- /dev/null +++ b/spec/mongoid/criteria_search_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' + +describe 'Scoped full-text searches' do + it 'combines criteria with locale filtering' do + I18n.with_locale(:en) do + included = LocalizedArtwork.create!(title_translations: { 'en' => 'Flowers' }, artist: 'Picasso') + LocalizedArtwork.create!(title_translations: { 'en' => 'Flowers' }, artist: 'Monet') + expect(LocalizedArtwork.where(artist: 'Picasso').fulltext_search('flowers')).to eq([included]) + end + end + + it 'applies criteria without raising for excluded documents' do + excluded = ScopedArtwork.create!(title: 'My Doc', value: 0) + included = ScopedArtwork.create!(title: 'My Doc 2', value: 10) + expect(ScopedArtwork.where(value: 10).fulltext_search('doc')).to eq([included]) + expect(ScopedArtwork.fulltext_search('doc')).to include(excluded, included) + end + + it 'fills the result limit after filtering higher-ranked documents' do + ScopedArtwork.create!(title: 'Flowers', value: 0) + included = ScopedArtwork.create!(title: 'Flowers in a vase', value: 10) + expect(ScopedArtwork.where(value: 10).fulltext_search('flowers', max_results: 1)).to eq([included]) + end + + it 'preserves scores and relevance order in a scoped search' do + first = ScopedArtwork.create!(title: 'Flowers', value: 10) + second = ScopedArtwork.create!(title: 'Flowers in a vase', value: 10) + results = ScopedArtwork.where(value: 10).fulltext_search('flowers', return_scores: true) + expect(results.map(&:first)).to eq([first, second]) + expect(results.first.last).to be > results.last.last + end + + it 'preserves the existing zero-limit behavior for unscoped searches' do + first = ScopedArtwork.create!(title: 'Flowers') + second = ScopedArtwork.create!(title: 'Flowers in a vase') + expect(ScopedArtwork.fulltext_search('flowers', max_results: 0)).to eq([first, second]) + end + + it 'searches descendants without returning parent or sibling documents' do + parent = ScopedArtwork.create!(title: 'My Doc') + child = InheritedArtwork.create!(title: 'My Inherited Doc') + grandchild = FurtherInheritedArtwork.create!(title: 'My Further Inherited Doc') + expect(ScopedArtwork.fulltext_search('doc')).to match_array([parent, child, grandchild]) + expect(InheritedArtwork.fulltext_search('doc')).to match_array([child, grandchild]) + expect(FurtherInheritedArtwork.fulltext_search('doc')).to eq([grandchild]) + end + + it 'does not raise for dangling index references even when Mongoid raises on find' do + artwork = ScopedArtwork.create!(title: 'Flowers') + artwork.delete + previous = Mongoid.raise_not_found_error + begin + Mongoid.raise_not_found_error = true + expect(ScopedArtwork.fulltext_search('flowers')).to eq([]) + expect(ScopedArtwork.fulltext_search('flowers', return_scores: true)).to eq([]) + ensure + Mongoid.raise_not_found_error = previous + end + end + + it 'keeps unscoped shared-index searches across unrelated models' do + artist = ExternalArtist.create!(full_name: 'Picasso') + artwork = ExternalArtwork.create!(title: 'Picasso') + expect(ExternalArtwork.fulltext_search('picasso')).to match_array([artist, artwork]) + expect(ExternalArtwork.where(title: 'Picasso').fulltext_search('picasso')).to eq([artwork]) + end +end