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.
@@ -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,7 @@
1
+ module PgSearch
2
+ module Features
3
+ end
4
+ end
5
+ require 'pg_search/features/dmetaphone'
6
+ require 'pg_search/features/trigram'
7
+ require 'pg_search/features/tsearch'
@@ -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,11 @@
1
+ require 'pg_search'
2
+ #require 'rails'
3
+
4
+ module PgSearch
5
+ class Railtie < Rails::Railtie
6
+ rake_tasks do
7
+ raise
8
+ load "pg_search/tasks.rb"
9
+ end
10
+ end
11
+ 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
@@ -0,0 +1,3 @@
1
+ module PgSearch
2
+ VERSION = "0.0.2"
3
+ 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