Skip to content
Closed
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
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,50 @@ one_close = Person.create!(:name => 'leigh heinz')
Person.search('ash hines') # => [exact, one_exact_one_close, one_exact]
```

##### :highlight (PostgreSQL 9.0 and newer only)

Setting this attribute to true will return an excerpt of the matching text for the search term in a `pg_highlight` attribute.

```ruby
class Person < ActiveRecord::Base
include PgSearch
pg_search_scope :search,
:against => :bio,
:using => {
:tsearch => {:highlight => true}
}
end

Person.create!(:bio => "Born in rural Alberta, where the buffalo roam.")

first_match = Person.search("Alberta").first
first_match.pg_highlight # => "Born in rural <b>Alberta</b>, where the buffalo roam."
```

By default, it will add the delimiters `<b>` and `</b>` around the matched text. You can customize these delimiters and the number of fragments returned with the `:start_sel`, `stop_sel`, and `max_fragments` options.

```ruby
class Person < ActiveRecord::Base
include PgSearch
pg_search_scope :search,
:against => :bio,
:using => {
:tsearch => {
:highlight => {
:start_sel => "*BEGIN*",
:stop_sel => "*END*",
:max_fragments => 1
}
}
}
end

Person.create!(:bio => "Born in rural Alberta, where the buffalo roam.")

first_match = Person.search("Alberta").first
first_match.pg_highlight # => "Born in rural *BEGIN*Alberta*END*, where the buffalo roam."
```

#### :dmetaphone (Double Metaphone soundalike search)

[Double Metaphone](http://en.wikipedia.org/wiki/Double_Metaphone) is an
Expand Down
33 changes: 32 additions & 1 deletion lib/pg_search/features/tsearch.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# rubocop:disable Metrics/ClassLength

require "pg_search/compatibility"
require "active_support/core_ext/module/delegation"

Expand All @@ -7,11 +9,19 @@ class TSearch < Feature
def initialize(*args)
super

if options[:prefix] && model.connection.send(:postgresql_version) < 80400
pg_version = model.connection.send(:postgresql_version)

if options[:prefix] && pg_version < 80400
raise PgSearch::NotSupportedForPostgresqlVersion.new(<<-MESSAGE.strip_heredoc)
Sorry, {:using => {:tsearch => {:prefix => true}}} only works in PostgreSQL 8.4 and above.")
MESSAGE
end

if options[:highlight] && pg_version < 90000
raise PgSearch::NotSupportedForPostgresqlVersion.new(<<-MESSAGE.strip_heredoc)
Sorry, {:using => {:tsearch => {:highlight => true}}} only works in PostgreSQL 9.0 and above.")
MESSAGE
end
end

def conditions
Expand All @@ -24,6 +34,10 @@ def rank
arel_wrap(tsearch_rank)
end

def highlight
arel_wrap(ts_headline)
end

private

DISALLOWED_TSQUERY_CHARACTERS = /['?\\:]/
Expand Down Expand Up @@ -101,6 +115,10 @@ def tsearch_rank
"ts_rank((#{tsdocument}), (#{tsquery}), #{normalization})"
end

def ts_headline
"ts_headline((#{document}), (#{tsquery}), '#{ts_headline_options}')"
end

def dictionary
Compatibility.build_quoted(options[:dictionary] || :simple)
end
Expand Down Expand Up @@ -129,6 +147,19 @@ def column_to_tsvector(search_column)
"setweight(#{tsvector}, #{connection.quote(search_column.weight)})"
end
end

def ts_headline_options
return nil unless options[:highlight].is_a?(Hash)

headline_options = {}
headline_options["StartSel"] = options[:highlight][:start_sel]
headline_options["StopSel"] = options[:highlight][:stop_sel]
headline_options["MaxFragments"] = options[:highlight][:max_fragments]

headline_options.map do |key, value|
"#{key} = #{value}" if value
end.compact.join(", ")
end
end
end
end
22 changes: 21 additions & 1 deletion lib/pg_search/scope_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def initialize(config)

def apply(scope)
scope.
select("#{quoted_table_name}.*, (#{rank}) AS pg_search_rank").
select(fields).
where(conditions).
order("pg_search_rank DESC, #{order_within_rank}").
joins(joins).
Expand All @@ -32,6 +32,22 @@ def eager_loading?

delegate :connection, :quoted_table_name, :to => :@model

def fields
["#{quoted_table_name}.*",
pg_search_rank_field,
pg_highlight_field].compact.join(", ")
end

def pg_search_rank_field
"(#{rank}) AS pg_search_rank"
end

def pg_highlight_field
if feature_options[:tsearch] && feature_options[:tsearch][:highlight]
"(#{highlight}) AS pg_highlight"
end
end

def conditions
config.features.reject do |feature_name, feature_options|
feature_options && feature_options[:sort_only]
Expand Down Expand Up @@ -86,5 +102,9 @@ def rank
feature_for($1).rank.to_sql
end
end

def highlight
feature_for(:tsearch).highlight.to_sql
end
end
end
49 changes: 48 additions & 1 deletion spec/integration/pg_search_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,52 @@
end
end

describe "highlighting" do
before do
["Strip Down", "Down", "Down and Out", "Won't Let You Down"].each do |name|
ModelWithPgSearch.create! :content => name
end
end

context "with highlight turned on" do
before do
ModelWithPgSearch.pg_search_scope :search_content,
:against => :content,
:using => {
:tsearch => {:highlight => true}
}
end

it "adds a #pg_highlight method to each returned model record" do
result = ModelWithPgSearch.search_content("Strip Down").first

expect(result.pg_highlight).to be_a(String)
end

it "returns excerpts of text where search match occurred" do
result = ModelWithPgSearch.search_content("Let").first

expect(result.pg_highlight).to eq("Won't <b>Let</b> You Down")
end
end

context "with highlight turned off" do
before do
ModelWithPgSearch.pg_search_scope :search_content,
:against => :content,
:using => {
:tsearch => {:highlight => false}
}
end

it "does not add a #pg_highlight method to each returned model record" do
result = ModelWithPgSearch.search_content("Strip Down").first

expect(result).to_not respond_to(:pg_highlight)
end
end
end

describe "ranking" do
before do
["Strip Down", "Down", "Down and Out", "Won't Let You Down"].each do |name|
Expand Down Expand Up @@ -717,7 +763,8 @@
it "should pass the custom configuration down to the specified feature" do
stub_feature = double(
:conditions => Arel::Nodes::Grouping.new(Arel.sql("1 = 1")),
:rank => Arel::Nodes::Grouping.new(Arel.sql("1.0"))
:rank => Arel::Nodes::Grouping.new(Arel.sql("1.0")),
:highlight => nil
)

expect(PgSearch::Features::TSearch).to receive(:new).with(anything, @tsearch_config, anything, anything, anything).at_least(:once).and_return(stub_feature)
Expand Down
84 changes: 84 additions & 0 deletions spec/lib/pg_search/features/tsearch_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,88 @@
end
end
end

describe "#highlight" do
with_model :Model do
table do |t|
t.string :name
t.text :content
end
end

context "when options[:highlight] is set" do
it "throws an error for PostgreSQL versions < 9.0" do
query = "query"
columns = [
PgSearch::Configuration::Column.new(:name, nil, Model),
PgSearch::Configuration::Column.new(:content, nil, Model),
]
options = { highlight: true }
config = double(:config, :ignore => [])
normalizer = PgSearch::Normalizer.new(config)

connection = double(:connection, :postgresql_version => 80400)
mock_model = double(:model, :connection => connection)

expect {
described_class.new(query, options, columns, mock_model, normalizer)
}.to raise_error(PgSearch::NotSupportedForPostgresqlVersion)
end

it "returns an expression using the ts_headline() function" do
query = "query"
columns = [
PgSearch::Configuration::Column.new(:name, nil, Model),
PgSearch::Configuration::Column.new(:content, nil, Model),
]
options = { highlight: true }
config = double(:config, :ignore => [])
normalizer = PgSearch::Normalizer.new(config)

feature = described_class.new(query, options, columns, Model, normalizer)

expect(feature.highlight.to_sql).to eq(
%Q{(ts_headline((coalesce(#{Model.quoted_table_name}."name"::text, '') || ' ' || coalesce(#{Model.quoted_table_name}."content"::text, '')), (to_tsquery('simple', ''' ' || 'query' || ' ''')), ''))}
)
end
end

context "when options[:highlight] includes :start_sel and :stop_sel" do
it "allows for custom query delimiters" do
query = "query"
columns = [
PgSearch::Configuration::Column.new(:name, nil, Model),
PgSearch::Configuration::Column.new(:content, nil, Model),
]
options = { highlight: { start_sel: "<match>", stop_sel: "</match>" } }
config = double(:config, :ignore => [])
normalizer = PgSearch::Normalizer.new(config)

feature = described_class.new(query, options, columns, Model, normalizer)

expect(feature.highlight.to_sql).to eq(
%Q{(ts_headline((coalesce(#{Model.quoted_table_name}."name"::text, '') || ' ' || coalesce(#{Model.quoted_table_name}."content"::text, '')), (to_tsquery('simple', ''' ' || 'query' || ' ''')), 'StartSel = <match>, StopSel = </match>'))}
)
end

it "allows a maximum number of fragments" do
query = "query"
columns = [
PgSearch::Configuration::Column.new(:name, nil, Model),
PgSearch::Configuration::Column.new(:content, nil, Model),
]
options = { highlight: { start_sel: "<match>",
stop_sel: "</match>",
max_fragments: 2 } }
config = double(:config, :ignore => [])
normalizer = PgSearch::Normalizer.new(config)

feature = described_class.new(query, options, columns, Model, normalizer)

expect(feature.highlight.to_sql).to eq(
%Q{(ts_headline((coalesce(#{Model.quoted_table_name}."name"::text, '') || ' ' || coalesce(#{Model.quoted_table_name}."content"::text, '')), (to_tsquery('simple', ''' ' || 'query' || ' ''')), 'StartSel = <match>, StopSel = </match>, MaxFragments = 2'))}
)
end
end
end
end