# frozen_string_literal: true require "spec_helper" # standard:disable RSpec/NestedGroups describe "an Active Record model which includes PgSearch" do with_model :ModelWithPgSearch do table do |t| t.string "title" t.text "content" t.integer "parent_model_id" t.integer "importance" end model do include PgSearch::Model belongs_to :parent_model end end with_model :ParentModel do table do |t| t.boolean :active, default: true end model do include PgSearch::Model has_many :models_with_pg_search scope :active, -> { where(active: true) } end end describe ".pg_search_scope" do it "builds a chainable scope" do ModelWithPgSearch.pg_search_scope "matching_query", against: [] scope = ModelWithPgSearch.where("1 = 1").matching_query("foo").where("1 = 1") expect(scope).to be_an ActiveRecord::Relation end context "when passed a lambda" do it "builds a dynamic scope" do ModelWithPgSearch.pg_search_scope :search_title_or_content, lambda { |query, pick_content| { query: query.gsub("-remove-", ""), against: pick_content ? :content : :title } } included = ModelWithPgSearch.create!(title: "foo", content: "bar") ModelWithPgSearch.create!(title: "bar", content: "foo") expect(ModelWithPgSearch.search_title_or_content("fo-remove-o", false)).to eq([included]) expect(ModelWithPgSearch.search_title_or_content("b-remove-ar", true)).to eq([included]) end end context "when passed an invalid argument" do it "builds a dynamic scope" do expect { ModelWithPgSearch.pg_search_scope :search_title_or_content, :some_symbol }.to( raise_exception(ArgumentError).with_message( "pg_search_scope expects a Hash or Proc" ) ) end end context "when an unknown option is passed in" do it "raises an exception when invoked" do ModelWithPgSearch.pg_search_scope :with_unknown_option, against: :content, foo: :bar expect { ModelWithPgSearch.with_unknown_option("foo") }.to raise_error(ArgumentError, /foo/) end context "with a lambda" do it "raises an exception when invoked" do ModelWithPgSearch.pg_search_scope :with_unknown_option, ->(*) { {against: :content, foo: :bar} } expect { ModelWithPgSearch.with_unknown_option("foo") }.to raise_error(ArgumentError, /foo/) end end end context "when an unknown :using is passed" do it "raises an exception when invoked" do ModelWithPgSearch.pg_search_scope :with_unknown_using, against: :content, using: :foo expect { ModelWithPgSearch.with_unknown_using("foo") }.to raise_error(ArgumentError, /foo/) end context "with a lambda" do it "raises an exception when invoked" do ModelWithPgSearch.pg_search_scope :with_unknown_using, ->(*) { {against: :content, using: :foo} } expect { ModelWithPgSearch.with_unknown_using("foo") }.to raise_error(ArgumentError, /foo/) end end end context "when an unknown :ignoring is passed" do it "raises an exception when invoked" do ModelWithPgSearch.pg_search_scope :with_unknown_ignoring, against: :content, ignoring: :foo expect { ModelWithPgSearch.with_unknown_ignoring("foo") }.to raise_error(ArgumentError, /ignoring.*foo/) end context "with a lambda" do it "raises an exception when invoked" do ModelWithPgSearch.pg_search_scope :with_unknown_ignoring, ->(*) { {against: :content, ignoring: :foo} } expect { ModelWithPgSearch.with_unknown_ignoring("foo") }.to raise_error(ArgumentError, /ignoring.*foo/) end end context "when :against is not passed in" do it "raises an exception when invoked" do ModelWithPgSearch.pg_search_scope :with_unknown_ignoring, {} expect { ModelWithPgSearch.with_unknown_ignoring("foo") }.to raise_error(ArgumentError, /against/) end context "with a lambda" do it "raises an exception when invoked" do ModelWithPgSearch.pg_search_scope :with_unknown_ignoring, ->(*) { {} } expect { ModelWithPgSearch.with_unknown_ignoring("foo") }.to raise_error(ArgumentError, /against/) end end context "when a tsvector column is specified" do it "does not raise an exception when invoked" do ModelWithPgSearch.pg_search_scope :with_unknown_ignoring, { using: { tsearch: { tsvector_column: "tsv" } } } expect { ModelWithPgSearch.with_unknown_ignoring("foo") }.not_to raise_error end end end end end describe "a search scope" do context "when against a single column" do before do ModelWithPgSearch.pg_search_scope :search_content, against: :content end context "when chained after a select() scope" do it "honors the select" do included = ModelWithPgSearch.create!(content: "foo", title: "bar") excluded = ModelWithPgSearch.create!(content: "bar", title: "foo") results = ModelWithPgSearch.select("id, title").search_content("foo") expect(results).to include(included) expect(results).not_to include(excluded) expect(results.first.attributes.key?("content")).to be false expect(results.select { |record| record.title == "bar" }).to eq [included] expect(results.reject { |record| record.title == "bar" }).to be_empty end end context "when chained before a select() scope" do it "honors the select" do included = ModelWithPgSearch.create!(content: "foo", title: "bar") excluded = ModelWithPgSearch.create!(content: "bar", title: "foo") results = ModelWithPgSearch.search_content("foo").select("id, title") expect(results).to include(included) expect(results).not_to include(excluded) expect(results.first.attributes.key?("content")).to be false expect(results.select { |record| record.title == "bar" }).to eq [included] expect(results.reject { |record| record.title == "bar" }).to be_empty end end context "when surrouned by select() scopes" do it "honors the select" do included = ModelWithPgSearch.create!(content: "foo", title: "bar") excluded = ModelWithPgSearch.create!(content: "bar", title: "foo") results = ModelWithPgSearch.select("id").search_content("foo").select("title") expect(results).to include(included) expect(results).not_to include(excluded) expect(results.first.attributes.key?("content")).to be false expect(results.select { |record| record.title == "bar" }).to eq [included] expect(results.reject { |record| record.title == "bar" }).to be_empty end end context "when chained to a cross-table scope" do with_model :House do table do |t| t.references :person t.string :city end model do include PgSearch::Model belongs_to :person pg_search_scope :search_city, against: [:city] end end with_model :Person do table do |t| t.string :name end model do include PgSearch::Model has_many :houses pg_search_scope :named, against: [:name] scope :with_house_in_city, lambda { |city| joins(:houses).where(House.table_name.to_sym => {city: city}) } scope :house_search_city, lambda { |query| joins(:houses).merge(House.search_city(query)) } end end it "works when the other scope is last" do house_in_duluth = House.create!(city: "Duluth") second_house_in_duluth = House.create!(city: "Duluth") house_in_sheboygan = House.create!(city: "Sheboygan") bob_in_duluth = Person.create!(name: "Bob", houses: [house_in_duluth]) bob_in_sheboygan = Person.create!(name: "Bob", houses: [house_in_sheboygan]) sally_in_duluth = Person.create!(name: "Sally", houses: [second_house_in_duluth]) results = Person.named("bob").with_house_in_city("Duluth") expect(results).to include bob_in_duluth expect(results).not_to include [bob_in_sheboygan, sally_in_duluth] end it "works when the other scope is first" do house_in_duluth = House.create!(city: "Duluth") second_house_in_duluth = House.create!(city: "Duluth") house_in_sheboygan = House.create!(city: "Sheboygan") bob_in_duluth = Person.create!(name: "Bob", houses: [house_in_duluth]) bob_in_sheboygan = Person.create!(name: "Bob", houses: [house_in_sheboygan]) sally_in_duluth = Person.create!(name: "Sally", houses: [second_house_in_duluth]) results = Person.with_house_in_city("Duluth").named("Bob") expect(results).to include bob_in_duluth expect(results).not_to include [bob_in_sheboygan, sally_in_duluth] end context "when chaining merged scopes" do it "does not raise an exception" do relation = Person.named("foo").house_search_city("bar") expect { relation.to_a }.not_to raise_error end end end context "when chaining scopes" do before do ModelWithPgSearch.pg_search_scope :search_title, against: :title end it "does not raise an exception" do relation = ModelWithPgSearch.search_content("foo").search_title("bar") expect { relation.to_a }.not_to raise_error end end it "returns an empty array when a blank query is passed in" do ModelWithPgSearch.create!(content: "foo") results = ModelWithPgSearch.search_content("") expect(results).to eq([]) end it "returns rows where the column contains the term in the query" do included = ModelWithPgSearch.create!(content: "foo") excluded = ModelWithPgSearch.create!(content: "bar") results = ModelWithPgSearch.search_content("foo") expect(results).to include(included) expect(results).not_to include(excluded) end it "returns the correct count" do ModelWithPgSearch.create!(content: "foo") ModelWithPgSearch.create!(content: "bar") results = ModelWithPgSearch.search_content("foo") expect(results.count).to eq 1 end it "returns the correct count(:all)" do ModelWithPgSearch.create!(content: "foo") ModelWithPgSearch.create!(content: "bar") results = ModelWithPgSearch.search_content("foo") expect(results.count(:all)).to eq 1 end it "supports #select" do record = ModelWithPgSearch.create!(content: "foo") ModelWithPgSearch.create!(content: "bar") records_with_only_id = ModelWithPgSearch.search_content("foo").select("id") expect(records_with_only_id.length).to eq 1 returned_record = records_with_only_id.first expect(returned_record.attributes).to eq("id" => record.id) end it "supports #pluck" do record = ModelWithPgSearch.create!(content: "foo") ModelWithPgSearch.create!(content: "bar") ids = ModelWithPgSearch.search_content("foo").pluck("id") expect(ids).to eq [record.id] end it "supports adding where clauses using the pg_search.rank" do ModelWithPgSearch.create!(content: "foo bar") twice = ModelWithPgSearch.create!(content: "foo foo") records = ModelWithPgSearch.search_content("foo") .where("#{PgSearch::Configuration.alias(ModelWithPgSearch.table_name)}.rank > 0.07") expect(records).to eq [twice] end it "returns rows where the column contains all the terms in the query in any order" do included = [ModelWithPgSearch.create!(content: "foo bar"), ModelWithPgSearch.create!(content: "bar foo")] excluded = ModelWithPgSearch.create!(content: "foo") results = ModelWithPgSearch.search_content("foo bar") expect(results).to match_array(included) expect(results).not_to include(excluded) end it "returns rows that match the query but not its case" do included = [ModelWithPgSearch.create!(content: "foo"), ModelWithPgSearch.create!(content: "FOO")] results = ModelWithPgSearch.search_content("Foo") expect(results).to match_array(included) end it "returns rows that match the query only if their accents match" do # \303\241 is a with acute accent # \303\251 is e with acute accent included = ModelWithPgSearch.create!(content: "abcd\303\251f") excluded = ModelWithPgSearch.create!(content: "\303\241bcdef") results = ModelWithPgSearch.search_content("abcd\303\251f") expect(results).to eq([included]) expect(results).not_to include(excluded) end it "returns rows that match the query but not rows that are prefixed by the query" do included = ModelWithPgSearch.create!(content: "pre") excluded = ModelWithPgSearch.create!(content: "prefix") results = ModelWithPgSearch.search_content("pre") expect(results).to eq([included]) expect(results).not_to include(excluded) end it "returns rows that match the query exactly and not those that match the query when stemmed by the default english dictionary" do included = ModelWithPgSearch.create!(content: "jumped") ModelWithPgSearch.create!(content: "jump") ModelWithPgSearch.create!(content: "jumping") results = ModelWithPgSearch.search_content("jumped") expect(results).to eq([included]) end it "returns rows that match sorted by rank" do loser = ModelWithPgSearch.create!(content: "foo") winner = ModelWithPgSearch.create!(content: "foo foo") results = ModelWithPgSearch.search_content("foo").with_pg_search_rank expect(results[0].pg_search_rank).to be > results[1].pg_search_rank expect(results).to eq([winner, loser]) end it "preserves column selection when with_pg_search_rank is chained after a select()" do ModelWithPgSearch.create!(title: "foo", content: "bar") results = ModelWithPgSearch.search_content("bar").select(:content).with_pg_search_rank expect(results.length).to be 1 expect(results.first.as_json.keys).to contain_exactly("id", "content", "pg_search_rank") end it "allows pg_search_rank along with a join" do parent_1 = ParentModel.create!(id: 98) parent_2 = ParentModel.create!(id: 99) loser = ModelWithPgSearch.create!(content: "foo", parent_model: parent_2) winner = ModelWithPgSearch.create!(content: "foo foo", parent_model: parent_1) results = ModelWithPgSearch.joins(:parent_model).merge(ParentModel.active).search_content("foo").with_pg_search_rank expect(results.map(&:id)).to eq [winner.id, loser.id] expect(results[0].pg_search_rank).to be > results[1].pg_search_rank expect(results).to eq([winner, loser]) end it "returns results that match sorted by primary key for records that rank the same" do sorted_results = [ModelWithPgSearch.create!(content: "foo"), ModelWithPgSearch.create!(content: "foo")].sort_by(&:id) results = ModelWithPgSearch.search_content("foo") expect(results).to eq(sorted_results) end it "returns results that match a query with multiple space-separated search terms" do included = [ ModelWithPgSearch.create!(content: "foo bar"), ModelWithPgSearch.create!(content: "bar foo"), ModelWithPgSearch.create!(content: "bar foo baz") ] excluded = [ ModelWithPgSearch.create!(content: "foo"), ModelWithPgSearch.create!(content: "foo baz") ] results = ModelWithPgSearch.search_content("foo bar") expect(results).to match_array(included) expect(results).not_to include(excluded) end it "returns rows that match a query with characters that are invalid in a tsquery expression" do included = ModelWithPgSearch.create!(content: "(:Foo.) Bar?, \\") results = ModelWithPgSearch.search_content("foo :bar .,?() \\") expect(results).to eq([included]) end it "accepts non-string queries and calls #to_s on them" do foo = ModelWithPgSearch.create!(content: "foo") not_a_string = instance_double(Object, to_s: "foo") expect(ModelWithPgSearch.search_content(not_a_string)).to eq([foo]) end context "when the column is not text" do with_model :ModelWithTimestamps do table do |t| t.timestamps null: false end model do include PgSearch::Model # WARNING: searching timestamps is not something PostgreSQL # full-text search is good at. Use at your own risk. pg_search_scope :search_timestamps, against: %i[created_at updated_at] end end it "casts the column to text" do record = ModelWithTimestamps.create! query = record.created_at.strftime("%Y-%m-%d") results = ModelWithTimestamps.search_timestamps(query) expect(results).to eq([record]) end end end context "when against multiple columns" do before do ModelWithPgSearch.pg_search_scope :search_title_and_content, against: %i[title content] end it "returns rows whose columns contain all of the terms in the query across columns" do included = [ ModelWithPgSearch.create!(title: "foo", content: "bar"), ModelWithPgSearch.create!(title: "bar", content: "foo") ] excluded = [ ModelWithPgSearch.create!(title: "foo", content: "foo"), ModelWithPgSearch.create!(title: "bar", content: "bar") ] results = ModelWithPgSearch.search_title_and_content("foo bar") expect(results).to match_array(included) excluded.each do |result| expect(results).not_to include(result) end end it "returns rows where at one column contains all of the terms in the query and another does not" do in_title = ModelWithPgSearch.create!(title: "foo", content: "bar") in_content = ModelWithPgSearch.create!(title: "bar", content: "foo") results = ModelWithPgSearch.search_title_and_content("foo") expect(results).to contain_exactly(in_title, in_content) end # Searching with a NULL column will prevent any matches unless we coalesce it. it "returns rows where at one column contains all of the terms in the query and another is NULL" do included = ModelWithPgSearch.create!(title: "foo", content: nil) results = ModelWithPgSearch.search_title_and_content("foo") expect(results).to eq([included]) end end context "when using trigram" do before do ModelWithPgSearch.pg_search_scope :with_trigrams, against: %i[title content], using: :trigram end it "returns rows where one searchable column and the query share enough trigrams" do included = ModelWithPgSearch.create!(title: "abcdefghijkl", content: nil) results = ModelWithPgSearch.with_trigrams("cdefhijkl") expect(results).to eq([included]) end it "returns rows where multiple searchable columns and the query share enough trigrams" do included = ModelWithPgSearch.create!(title: "abcdef", content: "ghijkl") results = ModelWithPgSearch.with_trigrams("cdefhijkl") expect(results).to eq([included]) end context "when a threshold is specified" do before do ModelWithPgSearch.pg_search_scope :with_strict_trigrams, against: %i[title content], using: {trigram: {threshold: 0.5}} ModelWithPgSearch.pg_search_scope :with_permissive_trigrams, against: %i[title content], using: {trigram: {threshold: 0.1}} end it "uses the threshold in the trigram expression" do low_similarity = ModelWithPgSearch.create!(title: "a") medium_similarity = ModelWithPgSearch.create!(title: "abc") high_similarity = ModelWithPgSearch.create!(title: "abcdefghijkl") results = ModelWithPgSearch.with_strict_trigrams("abcdefg") expect(results).to include(high_similarity) expect(results).not_to include(medium_similarity, low_similarity) results = ModelWithPgSearch.with_trigrams("abcdefg") expect(results).to include(high_similarity, medium_similarity) expect(results).not_to include(low_similarity) results = ModelWithPgSearch.with_permissive_trigrams("abcdefg") expect(results).to include(high_similarity, medium_similarity, low_similarity) end end end context "when using tsearch" do before do ModelWithPgSearch.pg_search_scope :search_title_with_prefixes, against: :title, using: { tsearch: {prefix: true} } end context "with prefix: true" do it "returns rows that match the query and that are prefixed by the query" do included = ModelWithPgSearch.create!(title: "prefix") excluded = ModelWithPgSearch.create!(title: "postfix") results = ModelWithPgSearch.search_title_with_prefixes("pre") expect(results).to eq([included]) expect(results).not_to include(excluded) end it "returns rows that match the query when the query has a hyphen" do included = ModelWithPgSearch.create!(title: "foo-bar") excluded = ModelWithPgSearch.create!(title: "foo bar") results = ModelWithPgSearch.search_title_with_prefixes("foo-bar") expect(results).to include(included) expect(results).not_to include(excluded) end end context "with the english dictionary" do before do ModelWithPgSearch.pg_search_scope :search_content_with_english, against: :content, using: { tsearch: {dictionary: :english} } end it "returns rows that match the query when stemmed by the english dictionary" do included = [ModelWithPgSearch.create!(content: "jump"), ModelWithPgSearch.create!(content: "jumped"), ModelWithPgSearch.create!(content: "jumping")] results = ModelWithPgSearch.search_content_with_english("jump") expect(results).to match_array(included) end end describe "highlighting" do before do ["Strip Down", "Down", "Down and Out", "Won't Let You Down"].each do |name| ModelWithPgSearch.create! title: "Just a title", content: name end end context "with highlight turned on" do before do ModelWithPgSearch.pg_search_scope :search_content, against: :content end it "adds a #pg_search_highlight method to each returned model record" do result = ModelWithPgSearch.search_content("Strip Down").with_pg_search_highlight.first expect(result.pg_search_highlight).to be_a(String) end it "returns excerpts of text where search match occurred" do result = ModelWithPgSearch.search_content("Let").with_pg_search_highlight.first expect(result.pg_search_highlight).to eq("Won't Let You Down") end it "preserves column selection when with_pg_search_highlight is chained after a select()" do result = ModelWithPgSearch.search_content("Let").select(:content).with_pg_search_highlight.first expect(result.as_json.keys).to contain_exactly("id", "content", "pg_search_highlight") end end context "with custom highlighting options" do before do ModelWithPgSearch.create! content: "#{"text " * 2}Let #{"text " * 2}Let #{"text " * 2}" ModelWithPgSearch.pg_search_scope :search_content, against: :content, using: { tsearch: { highlight: { StartSel: '', StopSel: "", FragmentDelimiter: '', MaxFragments: 2, MaxWords: 2, MinWords: 1 } } } end it "applies the options to the excerpts" do result = ModelWithPgSearch.search_content("Let").with_pg_search_highlight.first expect(result.pg_search_highlight).to eq(%(Let textLet text)) end end end describe "ranking" do before do ["Strip Down", "Down", "Down and Out", "Won't Let You Down"].each do |name| ModelWithPgSearch.create! content: name end end it "adds a #pg_search_rank method to each returned model record" do ModelWithPgSearch.pg_search_scope :search_content, against: :content result = ModelWithPgSearch.search_content("Strip Down").with_pg_search_rank.first expect(result.pg_search_rank).to be_a(Float) end context "with a normalization specified" do before do ModelWithPgSearch.pg_search_scope :search_content_with_normalization, against: :content, using: { tsearch: {normalization: 2} } end it "ranks the results for documents with less text higher" do results = ModelWithPgSearch.search_content_with_normalization("down").with_pg_search_rank expect(results.map(&:content)).to eq(["Down", "Strip Down", "Down and Out", "Won't Let You Down"]) expect(results.first.pg_search_rank).to be > results.last.pg_search_rank end end context "with no normalization" do before do ModelWithPgSearch.pg_search_scope :search_content_without_normalization, against: :content, using: :tsearch end it "ranks the results equally" do results = ModelWithPgSearch.search_content_without_normalization("down").with_pg_search_rank expect(results.map(&:content)).to eq(["Strip Down", "Down", "Down and Out", "Won't Let You Down"]) expect(results.first.pg_search_rank).to eq(results.last.pg_search_rank) end end end context "when against columns ranked with arrays" do before do ModelWithPgSearch.pg_search_scope :search_weighted_by_array_of_arrays, against: [[:content, "B"], [:title, "A"]] end it "returns results sorted by weighted rank" do loser = ModelWithPgSearch.create!(title: "bar", content: "foo") winner = ModelWithPgSearch.create!(title: "foo", content: "bar") results = ModelWithPgSearch.search_weighted_by_array_of_arrays("foo").with_pg_search_rank expect(results[0].pg_search_rank).to be > results[1].pg_search_rank expect(results).to eq([winner, loser]) end end context "when against columns ranked with a hash" do before do ModelWithPgSearch.pg_search_scope :search_weighted_by_hash, against: {content: "B", title: "A"} end it "returns results sorted by weighted rank" do loser = ModelWithPgSearch.create!(title: "bar", content: "foo") winner = ModelWithPgSearch.create!(title: "foo", content: "bar") results = ModelWithPgSearch.search_weighted_by_hash("foo").with_pg_search_rank expect(results[0].pg_search_rank).to be > results[1].pg_search_rank expect(results).to eq([winner, loser]) end end context "when against columns of which only some are ranked" do before do ModelWithPgSearch.pg_search_scope :search_weighted, against: [:content, [:title, "A"]] end it "returns results sorted by weighted rank using an implied low rank for unranked columns" do loser = ModelWithPgSearch.create!(title: "bar", content: "foo") winner = ModelWithPgSearch.create!(title: "foo", content: "bar") results = ModelWithPgSearch.search_weighted("foo").with_pg_search_rank expect(results[0].pg_search_rank).to be > results[1].pg_search_rank expect(results).to eq([winner, loser]) end end context "when searching any_word option" do before do ModelWithPgSearch.pg_search_scope :search_title_with_any_word, against: :title, using: { tsearch: {any_word: true} } ModelWithPgSearch.pg_search_scope :search_title_with_all_words, against: :title end it "returns all results containing any word in their title" do %w[one two three four].map { |number| ModelWithPgSearch.create!(title: number) } results = ModelWithPgSearch.search_title_with_any_word("one two three four") expect(results.map(&:title)).to eq(%w[one two three four]) results = ModelWithPgSearch.search_title_with_all_words("one two three four") expect(results.map(&:title)).to eq([]) end end context "with :negation" do before do ModelWithPgSearch.pg_search_scope :search_with_negation, against: :title, using: { tsearch: {negation: true} } end it "doesn't return results that contain terms prepended with '!'" do included = [ ModelWithPgSearch.create!(title: "one fish"), ModelWithPgSearch.create!(title: "two fish") ] excluded = [ ModelWithPgSearch.create!(title: "red fish"), ModelWithPgSearch.create!(title: "blue fish") ] results = ModelWithPgSearch.search_with_negation("fish !red !blue") expect(results).to include(*included) expect(results).not_to include(*excluded) end end context "without :negation" do before do ModelWithPgSearch.pg_search_scope :search_without_negation, against: :title, using: { tsearch: {} } end it "return results that contain terms prepended with '!'" do included = [ ModelWithPgSearch.create!(title: "!bang") ] excluded = [ ModelWithPgSearch.create!(title: "?question") ] results = ModelWithPgSearch.search_without_negation("!bang") expect(results).to include(*included) expect(results).not_to include(*excluded) end end end context "when using dmetaphone" do before do ModelWithPgSearch.pg_search_scope :with_dmetaphones, against: %i[title content], using: :dmetaphone end it "returns rows where one searchable column and the query share enough dmetaphones" do included = ModelWithPgSearch.create!(title: "Geoff", content: nil) ModelWithPgSearch.create!(title: "Bob", content: nil) results = ModelWithPgSearch.with_dmetaphones("Jeff") expect(results).to eq([included]) end it "returns rows where multiple searchable columns and the query share enough dmetaphones" do included = ModelWithPgSearch.create!(title: "Geoff", content: "George") ModelWithPgSearch.create!(title: "Bob", content: "Jones") results = ModelWithPgSearch.with_dmetaphones("Jeff Jorge") expect(results).to eq([included]) end it "returns rows that match dmetaphones that are English stopwords" do included = ModelWithPgSearch.create!(title: "White", content: nil) ModelWithPgSearch.create!(title: "Black", content: nil) results = ModelWithPgSearch.with_dmetaphones("Wight") expect(results).to eq([included]) end it "can handle terms that do not have a dmetaphone equivalent" do included = ModelWithPgSearch.create!(title: "White", content: nil) ModelWithPgSearch.create!(title: "Black", content: nil) # "W" does not have a dmetaphone equivalent results = ModelWithPgSearch.with_dmetaphones("Wight W") expect(results).to eq([included]) end end context "when using multiple features" do before do ModelWithPgSearch.pg_search_scope :with_tsearch, against: :title, using: [ [:tsearch, {dictionary: "english"}] ] ModelWithPgSearch.pg_search_scope :with_trigram, against: :title, using: :trigram ModelWithPgSearch.pg_search_scope :with_trigram_and_ignoring_accents, against: :title, ignoring: :accents, using: :trigram ModelWithPgSearch.pg_search_scope :with_tsearch_and_trigram, against: :title, using: [ [:tsearch, {dictionary: "english"}], :trigram ] ModelWithPgSearch.pg_search_scope :complex_search, against: %i[content title], ignoring: :accents, using: { tsearch: {dictionary: "english"}, dmetaphone: {}, trigram: {} } end it "returns rows that match using any of the features" do record = ModelWithPgSearch.create!(title: "tiling is grouty") # matches trigram only trigram_query = "ling is grouty" expect(ModelWithPgSearch.with_trigram(trigram_query)).to include(record) expect(ModelWithPgSearch.with_trigram_and_ignoring_accents(trigram_query)).to include(record) expect(ModelWithPgSearch.with_tsearch(trigram_query)).not_to include(record) expect(ModelWithPgSearch.with_tsearch_and_trigram(trigram_query)).to eq([record]) expect(ModelWithPgSearch.complex_search(trigram_query)).to include(record) # matches accent # \303\266 is o with diaeresis # \303\272 is u with acute accent accent_query = "gr\303\266\303\272ty" expect(ModelWithPgSearch.with_trigram(accent_query)).not_to include(record) expect(ModelWithPgSearch.with_trigram_and_ignoring_accents(accent_query)).to include(record) expect(ModelWithPgSearch.with_tsearch(accent_query)).not_to include(record) expect(ModelWithPgSearch.with_tsearch_and_trigram(accent_query).count(:all)).to eq(0) expect(ModelWithPgSearch.complex_search(accent_query)).to include(record) # matches tsearch only tsearch_query = "tiles" expect(ModelWithPgSearch.with_tsearch(tsearch_query)).to include(record) expect(ModelWithPgSearch.with_trigram(tsearch_query)).not_to include(record) expect(ModelWithPgSearch.with_trigram_and_ignoring_accents(tsearch_query)).not_to include(record) expect(ModelWithPgSearch.with_tsearch_and_trigram(tsearch_query)).to eq([record]) expect(ModelWithPgSearch.complex_search(tsearch_query)).to include(record) # matches dmetaphone only dmetaphone_query = "tyling" expect(ModelWithPgSearch.with_tsearch(dmetaphone_query)).not_to include(record) expect(ModelWithPgSearch.with_trigram(dmetaphone_query)).not_to include(record) expect(ModelWithPgSearch.with_trigram_and_ignoring_accents(dmetaphone_query)).not_to include(record) expect(ModelWithPgSearch.with_tsearch_and_trigram(dmetaphone_query)).not_to include(record) expect(ModelWithPgSearch.complex_search(dmetaphone_query)).to include(record) end context "with feature-specific configuration" do let(:tsearch_config) { {dictionary: "english"} } let(:trigram_config) { {foo: "bar"} } before do ModelWithPgSearch.pg_search_scope :with_tsearch_and_trigram_using_hash, against: :title, using: {tsearch: tsearch_config, trigram: trigram_config} end it "passes the custom configuration down to the specified feature" do tsearch_feature = instance_double( PgSearch::Features::TSearch, conditions: Arel::Nodes::Grouping.new(Arel.sql("1 = 1")), rank: Arel::Nodes::Grouping.new(Arel.sql("1.0")) ) trigram_feature = instance_double( PgSearch::Features::Trigram, conditions: Arel::Nodes::Grouping.new(Arel.sql("1 = 1")), rank: Arel::Nodes::Grouping.new(Arel.sql("1.0")) ) allow(PgSearch::Features::TSearch).to receive(:new).with(anything, tsearch_config, anything, anything, anything).and_return(tsearch_feature) allow(PgSearch::Features::Trigram).to receive(:new).with(anything, trigram_config, anything, anything, anything).and_return(trigram_feature) ModelWithPgSearch.with_tsearch_and_trigram_using_hash("foo") expect(PgSearch::Features::TSearch).to have_received(:new).with(anything, tsearch_config, anything, anything, anything).at_least(:once) expect(PgSearch::Features::Trigram).to have_received(:new).with(anything, trigram_config, anything, anything, anything).at_least(:once) end end end context "when using a tsvector column and an association" do with_model :Comment do table do |t| t.integer :post_id t.string :body end model do belongs_to :post end end with_model :Post do table do |t| t.text "content" t.tsvector "content_tsvector" end model do include PgSearch::Model has_many :comments end end let!(:expected) { Post.create!(content: "phooey") } let!(:unexpected) { Post.create!(content: "longcat is looooooooong") } before do ActiveRecord::Base.connection.execute <<~SQL.squish UPDATE #{Post.quoted_table_name} SET content_tsvector = to_tsvector('english'::regconfig, #{Post.quoted_table_name}."content") SQL expected.comments.create!(body: "commentone") unexpected.comments.create!(body: "commentwo") Post.pg_search_scope :search_by_content_with_tsvector, associated_against: {comments: [:body]}, using: { tsearch: { tsvector_column: "content_tsvector", dictionary: "english" } } end it "finds by the tsvector column" do expect(Post.search_by_content_with_tsvector("phooey").map(&:id)).to eq([expected.id]) end it "finds by the associated record" do expect(Post.search_by_content_with_tsvector("commentone").map(&:id)).to eq([expected.id]) end it "finds by a combination of the two" do expect(Post.search_by_content_with_tsvector("phooey commentone").map(&:id)).to eq([expected.id]) end end context "when using multiple tsvector columns" do with_model :ModelWithTsvector do model do include PgSearch::Model pg_search_scope :search_by_multiple_tsvector_columns, against: ["content", "message"], using: { tsearch: { tsvector_column: ["content_tsvector", "message_tsvector"], dictionary: "english" } } end end it "concats tsvector columns" do expected = "#{ModelWithTsvector.quoted_table_name}.\"content_tsvector\" || " \ "#{ModelWithTsvector.quoted_table_name}.\"message_tsvector\"" expect(ModelWithTsvector.search_by_multiple_tsvector_columns("something").to_sql).to include(expected) end end context "when using a tsvector column with" do with_model :ModelWithTsvector do table do |t| t.text "content" t.tsvector "content_tsvector" end model { include PgSearch::Model } end let!(:expected) { ModelWithTsvector.create!(content: "tiling is grouty") } before do ModelWithTsvector.create!(content: "longcat is looooooooong") ActiveRecord::Base.connection.execute <<~SQL.squish UPDATE #{ModelWithTsvector.quoted_table_name} SET content_tsvector = to_tsvector('english'::regconfig, #{ModelWithTsvector.quoted_table_name}."content") SQL ModelWithTsvector.pg_search_scope :search_by_content_with_tsvector, against: :content, using: { tsearch: { tsvector_column: "content_tsvector", dictionary: "english" } } end it "does not use to_tsvector in the query" do expect(ModelWithTsvector.search_by_content_with_tsvector("tiles").to_sql).not_to match(/to_tsvector/) end it "finds the expected result" do expect(ModelWithTsvector.search_by_content_with_tsvector("tiles").map(&:id)).to eq([expected.id]) end context "when joining to a table with a column of the same name" do with_model :AnotherModel do table do |t| t.string :content_tsvector # the type of the column doesn't matter t.belongs_to :model_with_tsvector, index: false end end before do ModelWithTsvector.has_many :another_models end it "refers to the tsvector column in the query unambiguously" do expect { ModelWithTsvector.joins(:another_models).search_by_content_with_tsvector("test").to_a }.not_to raise_exception end end end context "when ignoring accents" do before do ModelWithPgSearch.pg_search_scope :search_title_without_accents, against: :title, ignoring: :accents end it "returns rows that match the query but not its accents" do # \303\241 is a with acute accent # \303\251 is e with acute accent included = ModelWithPgSearch.create!(title: "\303\241bcdef") results = ModelWithPgSearch.search_title_without_accents("abcd\303\251f") expect(results).to eq([included]) end context "when the query includes accents" do let(:term) { "L#{%w[‘ ’ ʻ ʼ].sample}Content" } let(:included) { ModelWithPgSearch.create!(title: "Weird #{term}") } let(:results) { ModelWithPgSearch.search_title_without_accents(term) } before do ModelWithPgSearch.create!(title: "FooBar") end it "does not create an erroneous tsquery expression" do expect(results).to contain_exactly(included) end end end context "when passed a :ranked_by expression" do before do ModelWithPgSearch.pg_search_scope :search_content_with_default_rank, against: :content ModelWithPgSearch.pg_search_scope :search_content_with_importance_as_rank, against: :content, ranked_by: "importance" ModelWithPgSearch.pg_search_scope :search_content_with_importance_as_rank_multiplier, against: :content, ranked_by: ":tsearch * importance" end it "returns records with a rank attribute equal to the :ranked_by expression" do ModelWithPgSearch.create!(content: "foo", importance: 10) results = ModelWithPgSearch.search_content_with_importance_as_rank("foo").with_pg_search_rank expect(results.first.pg_search_rank).to eq(10) end it "substitutes :tsearch with the tsearch rank expression in the :ranked_by expression" do ModelWithPgSearch.create!(content: "foo", importance: 10) tsearch_result = ModelWithPgSearch.search_content_with_default_rank("foo").with_pg_search_rank.first tsearch_rank = tsearch_result.pg_search_rank multiplied_result = ModelWithPgSearch.search_content_with_importance_as_rank_multiplier("foo") .with_pg_search_rank .first multiplied_rank = multiplied_result.pg_search_rank expect(multiplied_rank).to be_within(0.001).of(tsearch_rank * 10) end it "returns results in descending order of the value of the rank expression" do records = [ ModelWithPgSearch.create!(content: "foo", importance: 1), ModelWithPgSearch.create!(content: "foo", importance: 3), ModelWithPgSearch.create!(content: "foo", importance: 2) ] results = ModelWithPgSearch.search_content_with_importance_as_rank("foo") expect(results).to eq(records.sort_by(&:importance).reverse) end %w[tsearch trigram dmetaphone].each do |feature| context "using the #{feature} ranking algorithm" do let(:scope_name) { :"search_content_ranked_by_#{feature}" } before do ModelWithPgSearch.pg_search_scope scope_name, against: :content, ranked_by: ":#{feature}" ModelWithPgSearch.create!(content: "foo") end context "when .with_pg_search_rank is chained after" do specify "its results respond to #pg_search_rank" do result = ModelWithPgSearch.send(scope_name, "foo").with_pg_search_rank.first expect(result).to respond_to(:pg_search_rank) end it "returns the rank when #pg_search_rank is called on a result" do results = ModelWithPgSearch.send(scope_name, "foo").with_pg_search_rank expect(results.first.pg_search_rank).to be_a Float end end context "when .with_pg_search_rank is not chained after" do specify "its results do not respond to #pg_search_rank" do result = ModelWithPgSearch.send(scope_name, "foo").first expect(result).not_to respond_to(:pg_search_rank) end it "raises PgSearch::PgSearchRankNotSelected when #pg_search_rank is called on a result" do result = ModelWithPgSearch.send(scope_name, "foo").first expect { result.pg_search_rank }.to raise_exception(PgSearch::PgSearchRankNotSelected) end end end end context "when using the tsearch ranking algorithm" do it "sorts results by the tsearch rank" do ModelWithPgSearch.pg_search_scope :search_content_ranked_by_tsearch, using: :tsearch, against: :content, ranked_by: ":tsearch" once = ModelWithPgSearch.create!(content: "foo bar") twice = ModelWithPgSearch.create!(content: "foo foo") results = ModelWithPgSearch.search_content_ranked_by_tsearch("foo") expect(results.find_index(twice)).to be < results.find_index(once) end end context "when using the trigram ranking algorithm" do it "sorts results by the trigram rank" do ModelWithPgSearch.pg_search_scope :search_content_ranked_by_trigram, using: :trigram, against: :content, ranked_by: ":trigram" close = ModelWithPgSearch.create!(content: "abcdef") exact = ModelWithPgSearch.create!(content: "abc") results = ModelWithPgSearch.search_content_ranked_by_trigram("abc") expect(results.find_index(exact)).to be < results.find_index(close) end end context "when using the dmetaphone ranking algorithm" do it "sorts results by the dmetaphone rank" do ModelWithPgSearch.pg_search_scope :search_content_ranked_by_dmetaphone, using: :dmetaphone, against: :content, ranked_by: ":dmetaphone" once = ModelWithPgSearch.create!(content: "Phoo Bar") twice = ModelWithPgSearch.create!(content: "Phoo Fu") results = ModelWithPgSearch.search_content_ranked_by_dmetaphone("foo") expect(results.find_index(twice)).to be < results.find_index(once) end end end context "when there is a sort only feature" do it "excludes that feature from the conditions, but uses it in the sorting" do ModelWithPgSearch.pg_search_scope :search_content_ranked_by_dmetaphone, against: :content, using: { tsearch: {any_word: true, prefix: true}, dmetaphone: {any_word: true, prefix: true, sort_only: true} }, ranked_by: ":tsearch + (0.5 * :dmetaphone)" exact = ModelWithPgSearch.create!(content: "ash hines") one_exact_one_close = ModelWithPgSearch.create!(content: "ash heinz") one_exact = ModelWithPgSearch.create!(content: "ash smith") ModelWithPgSearch.create!(content: "leigh heinz") results = ModelWithPgSearch.search_content_ranked_by_dmetaphone("ash hines") expect(results).to eq [exact, one_exact_one_close, one_exact] end end end end # standard:enable RSpec/NestedGroups