pg_search 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.autotest +5 -0
- data/.gitignore +7 -0
- data/.rvmrc +1 -0
- data/CHANGELOG +7 -0
- data/Gemfile +5 -0
- data/LICENSE +19 -0
- data/README.rdoc +290 -0
- data/Rakefile +35 -0
- data/TODO +12 -0
- data/gemfiles/Gemfile.common +9 -0
- data/gemfiles/rails2/Gemfile +4 -0
- data/gemfiles/rails3/Gemfile +4 -0
- data/lib/pg_search.rb +32 -0
- data/lib/pg_search/configuration.rb +73 -0
- data/lib/pg_search/configuration/column.rb +43 -0
- data/lib/pg_search/features.rb +7 -0
- data/lib/pg_search/features/dmetaphone.rb +28 -0
- data/lib/pg_search/features/trigram.rb +29 -0
- data/lib/pg_search/features/tsearch.rb +64 -0
- data/lib/pg_search/normalizer.rb +13 -0
- data/lib/pg_search/railtie.rb +11 -0
- data/lib/pg_search/scope.rb +31 -0
- data/lib/pg_search/scope_options.rb +75 -0
- data/lib/pg_search/tasks.rb +37 -0
- data/lib/pg_search/version.rb +3 -0
- data/pg_search.gemspec +19 -0
- data/script/setup-contrib +12 -0
- data/spec/associations_spec.rb +225 -0
- data/spec/pg_search_spec.rb +596 -0
- data/spec/spec_helper.rb +92 -0
- data/sql/dmetaphone.sql +4 -0
- data/sql/uninstall_dmetaphone.sql +1 -0
- metadata +103 -0
@@ -0,0 +1,73 @@
|
|
1
|
+
require "pg_search/configuration/column"
|
2
|
+
|
3
|
+
module PgSearch
|
4
|
+
class Configuration
|
5
|
+
def initialize(options, model)
|
6
|
+
options = options.reverse_merge(default_options)
|
7
|
+
assert_valid_options(options)
|
8
|
+
@options = options
|
9
|
+
@model = model
|
10
|
+
end
|
11
|
+
|
12
|
+
def columns
|
13
|
+
regular_columns + associated_columns
|
14
|
+
end
|
15
|
+
|
16
|
+
def regular_columns
|
17
|
+
Array(@options[:against]).map do |column_name, weight|
|
18
|
+
Column.new(column_name, weight, @model)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def associated_columns
|
23
|
+
return [] unless @options[:associated_against]
|
24
|
+
@options[:associated_against].map do |association, against|
|
25
|
+
Array(against).map do |column_name, weight|
|
26
|
+
Column.new(column_name, weight, @model, association)
|
27
|
+
end
|
28
|
+
end.flatten
|
29
|
+
end
|
30
|
+
|
31
|
+
def query
|
32
|
+
@options[:query].to_s
|
33
|
+
end
|
34
|
+
|
35
|
+
def normalizations
|
36
|
+
Array.wrap(@options[:normalizing])
|
37
|
+
end
|
38
|
+
|
39
|
+
def ranking_sql
|
40
|
+
@options[:ranked_by]
|
41
|
+
end
|
42
|
+
|
43
|
+
def features
|
44
|
+
Array(@options[:using])
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def default_options
|
50
|
+
{:using => :tsearch}
|
51
|
+
end
|
52
|
+
|
53
|
+
def assert_valid_options(options)
|
54
|
+
valid_keys = [:against, :ranked_by, :normalizing, :using, :query, :associated_against]
|
55
|
+
valid_values = {
|
56
|
+
:normalizing => [:diacritics]
|
57
|
+
}
|
58
|
+
|
59
|
+
raise ArgumentError, "the search scope #{@name} must have :against in its options" unless options[:against]
|
60
|
+
raise ArgumentError, ":associated_against requires ActiveRecord 3 or later" if options[:associated_against] && !defined?(ActiveRecord::Relation)
|
61
|
+
|
62
|
+
options.assert_valid_keys(valid_keys)
|
63
|
+
|
64
|
+
valid_values.each do |key, values_for_key|
|
65
|
+
Array.wrap(options[key]).each do |value|
|
66
|
+
unless values_for_key.include?(value)
|
67
|
+
raise ArgumentError, ":#{key} cannot accept #{value}"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module PgSearch
|
2
|
+
class Configuration
|
3
|
+
class Column
|
4
|
+
attr_reader :weight, :association
|
5
|
+
|
6
|
+
def initialize(column_name, weight, model, association = nil)
|
7
|
+
@column_name = column_name.to_s
|
8
|
+
@weight = weight
|
9
|
+
@model = model
|
10
|
+
@association = association
|
11
|
+
end
|
12
|
+
|
13
|
+
def table
|
14
|
+
foreign? ? @model.reflect_on_association(association).table_name : @model.table_name
|
15
|
+
end
|
16
|
+
|
17
|
+
def full_name
|
18
|
+
"#{@model.connection.quote_table_name(table)}.#{@model.connection.quote_column_name(@column_name)}"
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_sql
|
22
|
+
name = if foreign?
|
23
|
+
"#{self.subselect_alias}.#{self.alias}"
|
24
|
+
else
|
25
|
+
full_name
|
26
|
+
end
|
27
|
+
"coalesce(#{name}, '')"
|
28
|
+
end
|
29
|
+
|
30
|
+
def foreign?
|
31
|
+
@association.present?
|
32
|
+
end
|
33
|
+
|
34
|
+
def alias
|
35
|
+
["pg_search", table, association, @column_name].compact.join('_')
|
36
|
+
end
|
37
|
+
|
38
|
+
def subselect_alias
|
39
|
+
"#{self.alias}_subselect"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require "active_support/core_ext/module/delegation"
|
2
|
+
|
3
|
+
module PgSearch
|
4
|
+
module Features
|
5
|
+
class DMetaphone
|
6
|
+
delegate :conditions, :rank, :to => :'@tsearch'
|
7
|
+
|
8
|
+
# config is temporary as we refactor
|
9
|
+
def initialize(query, options, config, model, normalizer)
|
10
|
+
dmetaphone_normalizer = Normalizer.new(normalizer)
|
11
|
+
options = (options || {}).merge(:dictionary => 'simple')
|
12
|
+
@tsearch = TSearch.new(query, options, config, model, dmetaphone_normalizer)
|
13
|
+
end
|
14
|
+
|
15
|
+
# Decorates a normalizer with dmetaphone processing.
|
16
|
+
class Normalizer
|
17
|
+
def initialize(normalizer_to_wrap)
|
18
|
+
@decorated_normalizer = normalizer_to_wrap
|
19
|
+
end
|
20
|
+
|
21
|
+
def add_normalization(original_sql)
|
22
|
+
otherwise_normalized_sql = @decorated_normalizer.add_normalization(original_sql)
|
23
|
+
"pg_search_dmetaphone(#{otherwise_normalized_sql})"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require "active_support/core_ext/module/delegation"
|
2
|
+
|
3
|
+
module PgSearch
|
4
|
+
module Features
|
5
|
+
class Trigram
|
6
|
+
def initialize(query, options, columns, model, normalizer)
|
7
|
+
@query = query
|
8
|
+
@options = options
|
9
|
+
@columns = columns
|
10
|
+
@model = model
|
11
|
+
@normalizer = normalizer
|
12
|
+
end
|
13
|
+
|
14
|
+
def conditions
|
15
|
+
["(#{@normalizer.add_normalization(document)}) % #{@normalizer.add_normalization(":query")}", {:query => @query}]
|
16
|
+
end
|
17
|
+
|
18
|
+
def rank
|
19
|
+
["similarity((#{@normalizer.add_normalization(document)}), #{@normalizer.add_normalization(":query")})", {:query => @query}]
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def document
|
25
|
+
@columns.map { |column| column.to_sql }.join(" || ' ' || ")
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require "active_support/core_ext/module/delegation"
|
2
|
+
|
3
|
+
module PgSearch
|
4
|
+
module Features
|
5
|
+
class TSearch
|
6
|
+
delegate :connection, :quoted_table_name, :to => :'@model'
|
7
|
+
|
8
|
+
def initialize(query, options, columns, model, normalizer)
|
9
|
+
@query = query
|
10
|
+
@options = options || {}
|
11
|
+
@model = model
|
12
|
+
@columns = columns
|
13
|
+
@normalizer = normalizer
|
14
|
+
end
|
15
|
+
|
16
|
+
def conditions
|
17
|
+
["(#{tsdocument}) @@ (#{tsquery})", interpolations]
|
18
|
+
end
|
19
|
+
|
20
|
+
def rank
|
21
|
+
tsearch_rank
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def interpolations
|
27
|
+
{:query => @query.to_s, :dictionary => @options[:dictionary].to_s}
|
28
|
+
end
|
29
|
+
|
30
|
+
def document
|
31
|
+
@columns.map { |column| column.to_sql }.join(" || ' ' || ")
|
32
|
+
end
|
33
|
+
|
34
|
+
def tsquery
|
35
|
+
return "''" if @query.blank?
|
36
|
+
|
37
|
+
@query.split(" ").compact.map do |term|
|
38
|
+
sanitized_term = term.gsub(/['?\-\\]/, " ")
|
39
|
+
|
40
|
+
term_sql = @normalizer.add_normalization(connection.quote(sanitized_term))
|
41
|
+
|
42
|
+
# After this, the SQL expression evaluates to a string containing the term surrounded by single-quotes.
|
43
|
+
tsquery_sql = "#{connection.quote("'")} || #{term_sql} || #{connection.quote("'")}"
|
44
|
+
|
45
|
+
# Add tsearch prefix operator if we're using a prefix search.
|
46
|
+
tsquery_sql = "#{tsquery_sql} || #{connection.quote(':*')}" if @options[:prefix]
|
47
|
+
|
48
|
+
"to_tsquery(#{":dictionary," if @options[:dictionary]} #{tsquery_sql})"
|
49
|
+
end.join(" && ")
|
50
|
+
end
|
51
|
+
|
52
|
+
def tsdocument
|
53
|
+
@columns.map do |search_column|
|
54
|
+
tsvector = "to_tsvector(#{":dictionary," if @options[:dictionary]} #{@normalizer.add_normalization(search_column.to_sql)})"
|
55
|
+
search_column.weight.nil? ? tsvector : "setweight(#{tsvector}, #{connection.quote(search_column.weight)})"
|
56
|
+
end.join(" || ")
|
57
|
+
end
|
58
|
+
|
59
|
+
def tsearch_rank
|
60
|
+
["ts_rank((#{tsdocument}), (#{tsquery}))", interpolations]
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module PgSearch
|
2
|
+
class Normalizer
|
3
|
+
def initialize(config)
|
4
|
+
@config = config
|
5
|
+
end
|
6
|
+
|
7
|
+
def add_normalization(original_sql)
|
8
|
+
normalized_sql = original_sql
|
9
|
+
normalized_sql = "unaccent(#{normalized_sql})" if @config.normalizations.include?(:diacritics)
|
10
|
+
normalized_sql
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module PgSearch
|
2
|
+
class Scope
|
3
|
+
def initialize(name, model, scope_options_or_proc)
|
4
|
+
@name = name
|
5
|
+
@model = model
|
6
|
+
@options_proc = build_options_proc(scope_options_or_proc)
|
7
|
+
end
|
8
|
+
|
9
|
+
def to_proc
|
10
|
+
lambda { |*args|
|
11
|
+
config = Configuration.new(@options_proc.call(*args), @model)
|
12
|
+
ScopeOptions.new(@name, @model, config).to_hash
|
13
|
+
}
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def build_options_proc(scope_options_or_proc)
|
19
|
+
case scope_options_or_proc
|
20
|
+
when Proc
|
21
|
+
scope_options_or_proc
|
22
|
+
when Hash
|
23
|
+
lambda { |query|
|
24
|
+
scope_options_or_proc.reverse_merge(:query => query)
|
25
|
+
}
|
26
|
+
else
|
27
|
+
raise ArgumentError, "A PgSearch scope expects a Proc or Hash for its options"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
require "active_support/core_ext/module/delegation"
|
2
|
+
|
3
|
+
module PgSearch
|
4
|
+
class ScopeOptions
|
5
|
+
attr_reader :model
|
6
|
+
|
7
|
+
delegate :connection, :quoted_table_name, :sanitize_sql_array, :to => :model
|
8
|
+
|
9
|
+
def initialize(name, model, config)
|
10
|
+
@name = name
|
11
|
+
@model = model
|
12
|
+
@config = config
|
13
|
+
|
14
|
+
@feature_options = @config.features.inject({}) do |features_hash, (feature_name, feature_options)|
|
15
|
+
features_hash.merge(
|
16
|
+
feature_name => feature_options
|
17
|
+
)
|
18
|
+
end
|
19
|
+
@feature_names = @config.features.map { |feature_name, feature_options| feature_name }
|
20
|
+
end
|
21
|
+
|
22
|
+
def to_hash
|
23
|
+
{
|
24
|
+
:select => "#{quoted_table_name}.*, (#{rank}) AS pg_search_rank",
|
25
|
+
:conditions => conditions,
|
26
|
+
:order => "pg_search_rank DESC, #{primary_key} ASC",
|
27
|
+
:joins => joins
|
28
|
+
}
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def conditions
|
34
|
+
@feature_names.map { |feature_name| "(#{sanitize_sql_array(feature_for(feature_name).conditions)})" }.join(" OR ")
|
35
|
+
end
|
36
|
+
|
37
|
+
def primary_key
|
38
|
+
"#{quoted_table_name}.#{connection.quote_column_name(model.primary_key)}"
|
39
|
+
end
|
40
|
+
|
41
|
+
def joins
|
42
|
+
@config.associated_columns.map do |column|
|
43
|
+
select = "string_agg(#{column.full_name}, ' ') AS #{column.alias}"
|
44
|
+
relation = model.joins(column.association).select("#{primary_key} AS id, #{select}").group(primary_key)
|
45
|
+
"LEFT OUTER JOIN (#{relation.to_sql}) #{column.subselect_alias} ON #{column.subselect_alias}.id = #{primary_key}"
|
46
|
+
end.join(' ')
|
47
|
+
end
|
48
|
+
|
49
|
+
def feature_for(feature_name)
|
50
|
+
feature_name = feature_name.to_sym
|
51
|
+
|
52
|
+
feature_class = {
|
53
|
+
:dmetaphone => Features::DMetaphone,
|
54
|
+
:tsearch => Features::TSearch,
|
55
|
+
:trigram => Features::Trigram
|
56
|
+
}[feature_name]
|
57
|
+
|
58
|
+
raise ArgumentError.new("Unknown feature: #{feature_name}") unless feature_class
|
59
|
+
|
60
|
+
normalizer = Normalizer.new(@config)
|
61
|
+
|
62
|
+
feature_class.new(@config.query, @feature_options[feature_name], @config.columns, @model, normalizer)
|
63
|
+
end
|
64
|
+
|
65
|
+
def tsearch_rank
|
66
|
+
sanitize_sql_array(@feature_names[Features::TSearch].rank)
|
67
|
+
end
|
68
|
+
|
69
|
+
def rank
|
70
|
+
(@config.ranking_sql || ":tsearch").gsub(/:(\w*)/) do
|
71
|
+
sanitize_sql_array(feature_for($1).rank)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'rake'
|
2
|
+
require 'pg_search'
|
3
|
+
|
4
|
+
namespace :pg_search do
|
5
|
+
namespace :migration do
|
6
|
+
desc "Generate migration to add support functions for :dmetaphone"
|
7
|
+
task :dmetaphone do
|
8
|
+
now = Time.now.utc
|
9
|
+
filename = "#{now.strftime('%Y%m%d%H%M%S')}_add_pg_search_dmetaphone_support_functions.rb"
|
10
|
+
|
11
|
+
dmetaphone_sql = File.read(File.join(File.dirname(__FILE__), '..', '..', 'sql', 'dmetaphone.sql')).chomp
|
12
|
+
uninstall_dmetaphone_sql = File.read(File.join(File.dirname(__FILE__), '..', '..', 'sql', 'uninstall_dmetaphone.sql')).chomp
|
13
|
+
|
14
|
+
File.open(Rails.root + 'db' + 'migrate' + filename, 'wb') do |migration_file|
|
15
|
+
migration_file.puts <<-RUBY
|
16
|
+
class AddPgSearchDmetaphoneSupportFunctions < ActiveRecord::Migration
|
17
|
+
def self.up
|
18
|
+
say_with_time("Adding support functions for pg_search :dmetaphone") do
|
19
|
+
ActiveRecord::Base.connection.execute(<<-SQL)
|
20
|
+
#{dmetaphone_sql}
|
21
|
+
SQL
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.down
|
26
|
+
say_with_time("Dropping support functions for pg_search :dmetaphone") do
|
27
|
+
ActiveRecord::Base.connection.execute(<<-SQL)
|
28
|
+
#{uninstall_dmetaphone_sql}
|
29
|
+
SQL
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
RUBY
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
data/pg_search.gemspec
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "pg_search/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "pg_search"
|
7
|
+
s.version = PgSearch::VERSION
|
8
|
+
s.platform = Gem::Platform::RUBY
|
9
|
+
s.authors = ["Case Commons, LLC"]
|
10
|
+
s.email = ["casecommons-dev@googlegroups.com"]
|
11
|
+
s.homepage = "https://github.com/Casecommons/pg_search"
|
12
|
+
s.summary = %q{PgSearch builds ActiveRecord named scopes that take advantage of PostgreSQL's full text search}
|
13
|
+
s.description = %q{PgSearch builds ActiveRecord named scopes that take advantage of PostgreSQL's full text search}
|
14
|
+
|
15
|
+
s.files = `git ls-files`.split("\n")
|
16
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
17
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
18
|
+
s.require_paths = ["lib"]
|
19
|
+
end
|