pg_search 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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