pg_search 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|