diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 86aec3f..bfabb72 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: - { ruby: '3.1', mongo: 'mongo:5.0', mongoid: '7' } - { ruby: '3.2', mongo: 'mongo:6.0', mongoid: '8' } - { ruby: '3.3', mongo: 'mongo:7.0', mongoid: '9' } - - { ruby: '3.4', mongo: 'mongo:8.0', mongoid: '9' } + - { ruby: '3.4', mongo: 'mongo:8.0', mongoid: '9', coverage: true } - { ruby: '4.0', mongo: 'mongo:8.0', mongoid: '9' } experimental: [false] @@ -29,7 +29,7 @@ jobs: env: MONGOID_VERSION: ${{ matrix.entry.mongoid }} - COVERAGE: ${{ matrix.entry.ruby == '3.4' }} + COVERAGE: ${{ matrix.entry.coverage }} steps: - uses: actions/checkout@v6 @@ -44,7 +44,7 @@ jobs: run: bundle exec rspec - name: Coveralls - if: matrix.entry.ruby == '3.4' + if: matrix.entry.coverage uses: coverallsapp/github-action@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index d428e4a..0dcf5b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## 0.6.0 (next) +* [#156](https://github.com/mongoid/mongoid_search/pull/156): Cover relevance search, configuration, output and Rails indexing tasks; fix scalar relevance scores and highest-relevance-first ordering (see [UPGRADING](UPGRADING.md)) - [@dblock](https://github.com/dblock). * Your contribution here. * [#155](https://github.com/mongoid/mongoid_search/pull/155): Replace Code Climate with SimpleCov and Coveralls reporting in CI and update status badges - [@dblock](https://github.com/dblock). * [#154](https://github.com/mongoid/mongoid_search/pull/154): Test through Ruby 4.0, Mongoid 9, and MongoDB 8.0 with representative legacy coverage and explicitly bundle OpenStruct - [@dblock](https://github.com/dblock). diff --git a/Gemfile b/Gemfile index c7b5905..86000e3 100644 --- a/Gemfile +++ b/Gemfile @@ -23,6 +23,7 @@ group :development do gem 'database_cleaner-mongoid', '>= 2.0.0' gem 'mongoid-compatibility' gem 'ostruct' + gem 'railties', version =~ /^6/ ? '~> 5.2.0' : '~> 7.0.0' gem 'rake', '>= 12.3.3' gem 'rspec', '~> 3.1' gem 'rubocop', '1.56.0' diff --git a/README.md b/README.md index d48881e..6382955 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,10 @@ Product.full_text_search('amazing apple', relevant_search: true) Please note that relevant_search will return an Array and not a Criteria object. The search method should always be called in the end of the method chain. +Results are ordered by decreasing numeric relevance (the number of matching indexed keywords). This option uses MongoDB map-reduce, which is deprecated upstream and requires server-side JavaScript. + +Upgrading from an earlier release? See [UPGRADING](UPGRADING.md) for the relevance ordering change in 0.6.0. + ### index Default is `_keywords`. diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..5711a14 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,22 @@ +# Upgrading + +## Upgrading to 0.6.0 + +### Relevance search returns highest-relevance results first + +Searches with `relevant_search: true`, or with `Mongoid::Search.relevant_search = true`, now return results sorted by **decreasing numeric relevance**. Previously, results were sorted in ascending order, putting lower scores first; the map-reduce reducer could also return arrays instead of numeric scores. Relevance scores are now numeric counts of matching indexed keywords. + +For example, a search matching documents with relevance scores of `1` and `2` now returns the document with score `2` first: + +```ruby +Product.full_text_search('amazing apple', relevant_search: true).map(&:relevance) +# => [2.0, 1.0] +``` + +Remove any application-side `.reverse` or other sorting workaround used to put the most relevant results first. If your application intentionally needs the least relevant results first, sort the returned array explicitly: + +```ruby +Product.full_text_search('amazing apple', relevant_search: true).sort_by(&:relevance) +``` + +Relevance search still returns an `Array`, not a Mongoid criteria. Searches with relevance disabled (the default) are unaffected. No keyword reindexing is needed. diff --git a/lib/mongoid_search/mongoid_search.rb b/lib/mongoid_search/mongoid_search.rb index 18389b4..24e2d2e 100644 --- a/lib/mongoid_search/mongoid_search.rb +++ b/lib/mongoid_search/mongoid_search.rb @@ -94,7 +94,7 @@ def search_without_relevance(query, options) end def search_relevant(query, options) - results_with_relevance(query, options).sort_by { |o| o['value'] }.map do |r| + results_with_relevance(query, options).sort_by { |o| -o['value'] }.map do |r| new(r['_id'].merge(relevance: r['value'])) do |o| # Need to match the actual object o.instance_variable_set('@new_record', false) @@ -124,7 +124,7 @@ def results_with_relevance(query, options) reduce = %{ function(key, values) { - return(values); + return Array.sum(values); } } diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb new file mode 100644 index 0000000..d876aaf --- /dev/null +++ b/spec/configuration_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mongoid::Search do + it 'yields the public configuration module' do + expect { |block| described_class.setup(&block) }.to yield_with_args(described_class) + end +end diff --git a/spec/log_spec.rb b/spec/log_spec.rb new file mode 100644 index 0000000..805e768 --- /dev/null +++ b/spec/log_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mongoid::Search::Log do + around do |example| + original = described_class.silent + described_class.silent = false + example.run + ensure + described_class.silent = original + end + + it 'prints failures in red' do + expect { described_class.red('F') }.to output("\033[31mF\033[0m").to_stdout + end + + it 'prints successes in green' do + expect { described_class.green('.') }.to output("\033[32m.\033[0m").to_stdout + end + + it 'does not print when silent' do + described_class.silent = true + expect { described_class.red('F') }.not_to output.to_stdout + end +end diff --git a/spec/mongoid_search_spec.rb b/spec/mongoid_search_spec.rb index 2b91346..a6cb8c8 100644 --- a/spec/mongoid_search_spec.rb +++ b/spec/mongoid_search_spec.rb @@ -306,13 +306,16 @@ end end - # relevant search specs are currently broken due to calling sort on an array of arrays - xcontext 'relevant search' do + context 'relevant search' do before do Mongoid::Search.relevant_search = true @imac = Product.create name: 'apple imac' end + after do + Mongoid::Search.relevant_search = false + end + it 'should return results ordered by relevance and with correct ids' do expect(Product.full_text_search('apple imac').map(&:_id)).to eq [@imac._id, @product._id] end diff --git a/spec/railtie_spec.rb b/spec/railtie_spec.rb new file mode 100644 index 0000000..798556e --- /dev/null +++ b/spec/railtie_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rake' +require 'rails/railtie' +require 'rails/application' +require 'mongoid_search/railtie' + +describe Mongoid::Search::Railtie do + before(:all) do + @rake_application = Rake.application + Rake.application = Rake::Application.new + described_class.instance.send(:run_tasks_blocks, described_class.instance) + Rake::Task.define_task(:environment) + end + + after(:all) do + Rake.application = @rake_application + end + + around do |example| + silent = Mongoid::Search::Log.silent + example.run + ensure + Rake::Task['mongoid_search:index'].reenable + Mongoid::Search::Log.silent = silent + end + + before do + application = instance_double(Rails::Application, eager_load!: nil) + allow(Rails).to receive(:application).and_return(application) + end + + it 'registers the public indexing rake task with Rails' do + expect(Rake::Task.task_defined?('mongoid_search:index')).to be true + expect(Rake::Task['mongoid_search:index'].prerequisites).to include('environment') + end + + it 'eager loads the application and reports when no models are registered' do + allow(Mongoid::Search).to receive(:classes).and_return([]) + expect(Rails.application).to receive(:eager_load!) + + expect { Rake::Task['mongoid_search:index'].invoke } + .to output("No model to index keywords.\n").to_stdout + end + + it 'indexes the registered models' do + product = Product.create!(name: 'Coverage') + Product.collection.find(_id: product.id).update_one('$unset' => { _keywords: '' }) + allow(Mongoid::Search).to receive(:classes).and_return([Product]) + expect(Rails.application).to receive(:eager_load!) + + expect { Rake::Task['mongoid_search:index'].invoke } + .to output(/Indexing documents for Product:.*Done/m).to_stdout + expect(product.reload._keywords).to eq(['coverage']) + end +end