Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `.
Expand Down
27 changes: 21 additions & 6 deletions lib/mongoid/full_text_search.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions spec/models/scoped_artwork.rb
Original file line number Diff line number Diff line change
@@ -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
67 changes: 67 additions & 0 deletions spec/mongoid/criteria_search_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading