textacular 3.0.0

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,227 @@
1
+ require 'active_record'
2
+
3
+ require 'textacular/version'
4
+
5
+ module Textacular
6
+ def self.searchable_language
7
+ 'english'
8
+ end
9
+
10
+ def search(query = "", exclusive = true)
11
+ basic_search(query, exclusive)
12
+ end
13
+
14
+ def basic_search(query = "", exclusive = true)
15
+ exclusive, query = munge_exclusive_and_query(exclusive, query)
16
+ parsed_query_hash = parse_query_hash(query)
17
+ similarities, conditions = basic_similarities_and_conditions(parsed_query_hash)
18
+ assemble_query(similarities, conditions, exclusive)
19
+ end
20
+
21
+ def advanced_search(query = "", exclusive = true)
22
+ exclusive, query = munge_exclusive_and_query(exclusive, query)
23
+ parsed_query_hash = parse_query_hash(query)
24
+ similarities, conditions = advanced_similarities_and_conditions(parsed_query_hash)
25
+ assemble_query(similarities, conditions, exclusive)
26
+ end
27
+
28
+ def fuzzy_search(query = '', exclusive = true)
29
+ exclusive, query = munge_exclusive_and_query(exclusive, query)
30
+ parsed_query_hash = parse_query_hash(query)
31
+ similarities, conditions = fuzzy_similarities_and_conditions(parsed_query_hash)
32
+ assemble_query(similarities, conditions, exclusive)
33
+ end
34
+
35
+ def method_missing(method, *search_terms)
36
+ return super if self == ActiveRecord::Base
37
+ if Helper.dynamic_search_method?(method, self.columns)
38
+ exclusive = Helper.exclusive_dynamic_search_method?(method, self.columns)
39
+ columns = exclusive ? Helper.exclusive_dynamic_search_columns(method) : Helper.inclusive_dynamic_search_columns(method)
40
+ metaclass = class << self; self; end
41
+ metaclass.__send__(:define_method, method) do |*args|
42
+ query = columns.inject({}) do |query, column|
43
+ query.merge column => args.shift
44
+ end
45
+ self.send(Helper.search_type(method), query, exclusive)
46
+ end
47
+ __send__(method, *search_terms, exclusive)
48
+ else
49
+ super
50
+ end
51
+ rescue ActiveRecord::StatementInvalid
52
+ super
53
+ end
54
+
55
+ def respond_to?(method, include_private = false)
56
+ return super if self == ActiveRecord::Base
57
+ Helper.dynamic_search_method?(method, self.columns) or super
58
+ rescue StandardError
59
+ super
60
+ end
61
+
62
+ private
63
+
64
+ def munge_exclusive_and_query(exclusive, query)
65
+ unless query.is_a?(Hash)
66
+ exclusive = false
67
+ query = searchable_columns.inject({}) do |terms, column|
68
+ terms.merge column => query.to_s
69
+ end
70
+ end
71
+
72
+ [exclusive, query]
73
+ end
74
+
75
+ def parse_query_hash(query, table_name = quoted_table_name)
76
+ table_name = connection.quote_table_name(table_name)
77
+
78
+ results = []
79
+
80
+ query.each do |column_or_table, search_term|
81
+ if search_term.is_a?(Hash)
82
+ results += parse_query_hash(search_term, column_or_table)
83
+ else
84
+ column = connection.quote_column_name(column_or_table)
85
+ search_term = connection.quote normalize(Helper.normalize(search_term))
86
+
87
+ results << [table_name, column, search_term]
88
+ end
89
+ end
90
+
91
+ results
92
+ end
93
+
94
+ def basic_similarities_and_conditions(parsed_query_hash)
95
+ parsed_query_hash.inject([[], []]) do |(similarities, conditions), query_args|
96
+ similarities << basic_similarity_string(*query_args)
97
+ conditions << basic_condition_string(*query_args)
98
+
99
+ [similarities, conditions]
100
+ end
101
+ end
102
+
103
+ def basic_similarity_string(table_name, column, search_term)
104
+ "ts_rank(to_tsvector(#{quoted_language}, #{table_name}.#{column}::text), plainto_tsquery(#{quoted_language}, #{search_term}::text))"
105
+ end
106
+
107
+ def basic_condition_string(table_name, column, search_term)
108
+ "to_tsvector(#{quoted_language}, #{table_name}.#{column}::text) @@ plainto_tsquery(#{quoted_language}, #{search_term}::text)"
109
+ end
110
+
111
+ def advanced_similarities_and_conditions(parsed_query_hash)
112
+ parsed_query_hash.inject([[], []]) do |(similarities, conditions), query_args|
113
+ similarities << advanced_similarity_string(*query_args)
114
+ conditions << advanced_condition_string(*query_args)
115
+
116
+ [similarities, conditions]
117
+ end
118
+ end
119
+
120
+ def advanced_similarity_string(table_name, column, search_term)
121
+ "ts_rank(to_tsvector(#{quoted_language}, #{table_name}.#{column}::text), to_tsquery(#{quoted_language}, #{search_term}::text))"
122
+ end
123
+
124
+ def advanced_condition_string(table_name, column, search_term)
125
+ "to_tsvector(#{quoted_language}, #{table_name}.#{column}::text) @@ to_tsquery(#{quoted_language}, #{search_term}::text)"
126
+ end
127
+
128
+ def fuzzy_similarities_and_conditions(parsed_query_hash)
129
+ parsed_query_hash.inject([[], []]) do |(similarities, conditions), query_args|
130
+ similarities << fuzzy_similarity_string(*query_args)
131
+ conditions << fuzzy_condition_string(*query_args)
132
+
133
+ [similarities, conditions]
134
+ end
135
+ end
136
+
137
+ def fuzzy_similarity_string(table_name, column, search_term)
138
+ "similarity(#{table_name}.#{column}, #{search_term})"
139
+ end
140
+
141
+ def fuzzy_condition_string(table_name, column, search_term)
142
+ "(#{table_name}.#{column} % #{search_term})"
143
+ end
144
+
145
+ def assemble_query(similarities, conditions, exclusive)
146
+ rank = connection.quote_column_name('rank' + rand.to_s)
147
+
148
+ select("#{quoted_table_name + '.*,' if scoped.select_values.empty?} #{similarities.join(" + ")} AS #{rank}").
149
+ where(conditions.join(exclusive ? " AND " : " OR ")).
150
+ order("#{rank} DESC")
151
+ end
152
+
153
+ def normalize(query)
154
+ query
155
+ end
156
+
157
+ def searchable_columns
158
+ columns.select {|column| [:string, :text].include? column.type }.map(&:name)
159
+ end
160
+
161
+ def quoted_language
162
+ @quoted_language ||= connection.quote(searchable_language)
163
+ end
164
+
165
+ def searchable_language
166
+ Textacular.searchable_language
167
+ end
168
+
169
+ module Helper
170
+ class << self
171
+ def normalize(query)
172
+ query.to_s.gsub(' ', '\\\\ ')
173
+ end
174
+
175
+ def method_name_regex
176
+ /^(?<search_type>((basic|advanced|fuzzy)_)?search)_by_(?<columns>[_a-zA-Z]\w*)$/
177
+ end
178
+
179
+ def search_type(method)
180
+ method.to_s.match(method_name_regex)[:search_type]
181
+ end
182
+
183
+ def exclusive_dynamic_search_columns(method)
184
+ if match = method.to_s.match(method_name_regex)
185
+ match[:columns].split('_and_')
186
+ else
187
+ []
188
+ end
189
+ end
190
+
191
+ def inclusive_dynamic_search_columns(method)
192
+ if match = method.to_s.match(method_name_regex)
193
+ match[:columns].split('_or_')
194
+ else
195
+ []
196
+ end
197
+ end
198
+
199
+ def exclusive_dynamic_search_method?(method, class_columns)
200
+ string_columns = class_columns.map(&:name)
201
+ columns = exclusive_dynamic_search_columns(method)
202
+ unless columns.empty?
203
+ columns.all? {|column| string_columns.include?(column) }
204
+ else
205
+ false
206
+ end
207
+ end
208
+
209
+ def inclusive_dynamic_search_method?(method, class_columns)
210
+ string_columns = class_columns.map(&:name)
211
+ columns = inclusive_dynamic_search_columns(method)
212
+ unless columns.empty?
213
+ columns.all? {|column| string_columns.include?(column) }
214
+ else
215
+ false
216
+ end
217
+ end
218
+
219
+ def dynamic_search_method?(method, class_columns)
220
+ exclusive_dynamic_search_method?(method, class_columns) or
221
+ inclusive_dynamic_search_method?(method, class_columns)
222
+ end
223
+ end
224
+ end
225
+ end
226
+
227
+ require File.expand_path(File.dirname(__FILE__) + '/textacular/full_text_indexer')
@@ -0,0 +1,79 @@
1
+ class Textacular::FullTextIndexer
2
+ def generate_migration(model_name)
3
+ stream_output do |io|
4
+ io.puts(<<-MIGRATION)
5
+ class #{model_name}FullTextSearch < ActiveRecord::Migration
6
+ def self.up
7
+ execute(<<-SQL.strip)
8
+ #{up_migration(model_name)}
9
+ SQL
10
+ end
11
+
12
+ def self.down
13
+ execute(<<-SQL.strip)
14
+ #{down_migration(model_name)}
15
+ SQL
16
+ end
17
+ end
18
+ MIGRATION
19
+ end
20
+ end
21
+
22
+ def stream_output(now = Time.now.utc, &block)
23
+ if !@output_stream && defined?(Rails)
24
+ File.open(migration_file_name(now), 'w', &block)
25
+ else
26
+ @output_stream ||= $stdout
27
+
28
+ yield @output_stream
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def migration_file_name(now = Time.now.utc)
35
+ File.join(Rails.root, 'db', 'migrate',"#{now.strftime('%Y%m%d%H%M%S')}_full_text_search.rb")
36
+ end
37
+
38
+ def up_migration(model_name)
39
+ migration_with_type(model_name, :up)
40
+ end
41
+
42
+ def down_migration(model_name)
43
+ migration_with_type(model_name, :down)
44
+ end
45
+
46
+ def migration_with_type(model_name, type)
47
+ sql_lines = ''
48
+
49
+ model = Kernel.const_get(model_name)
50
+ model.indexable_columns.each do |column|
51
+ sql_lines << drop_index_sql_for(model, column)
52
+ sql_lines << create_index_sql_for(model, column) if type == :up
53
+ end
54
+
55
+ sql_lines.strip.gsub("\n","\n ")
56
+ end
57
+
58
+ def drop_index_sql_for(model, column)
59
+ "DROP index IF EXISTS #{index_name_for(model, column)};\n"
60
+ end
61
+
62
+ def create_index_sql_for(model, column)
63
+ # The spacing gets sort of wonky in here.
64
+
65
+ <<-SQL
66
+ CREATE index #{index_name_for(model, column)}
67
+ ON #{model.table_name}
68
+ USING gin(to_tsvector("#{dictionary}", "#{model.table_name}"."#{column}"::text));
69
+ SQL
70
+ end
71
+
72
+ def index_name_for(model, column)
73
+ "#{model.table_name}_#{column}_fts_idx"
74
+ end
75
+
76
+ def dictionary
77
+ Textacular.searchable_language
78
+ end
79
+ end
@@ -0,0 +1,57 @@
1
+ module Textacular
2
+ class PostgresModuleInstaller
3
+ def install_module(module_name)
4
+ major, minor, patch = postgres_version.split('.')
5
+
6
+ if major.to_i >= 9 && minor.to_i >= 1
7
+ install_postgres_91_module(module_name)
8
+ else
9
+ install_postgres_90_module(module_name)
10
+ end
11
+ end
12
+
13
+ def db_name
14
+ @db_name ||= ActiveRecord::Base.connection.current_database
15
+ end
16
+
17
+ private
18
+
19
+ def postgres_version
20
+ @postgres_version ||= ask_pg_config('version').match(/PostgreSQL ([0-9]+(\.[0-9]+)*)/)[1]
21
+ end
22
+
23
+ def postgres_share_dir
24
+ @share_dir ||= ask_pg_config('sharedir')
25
+ end
26
+
27
+ def ask_pg_config(argument)
28
+ result = `pg_config --#{argument}`.chomp
29
+
30
+ raise RuntimeError, "Cannot find Postgres's #{argument}." unless $?.success?
31
+
32
+ result
33
+ end
34
+
35
+ def install_postgres_90_module(module_name)
36
+ module_location = "#{postgres_share_dir}/contrib/#{module_name}.sql"
37
+
38
+ unless system("ls #{module_location}")
39
+ raise RuntimeError, "Cannot find the #{module_name} module. Was it compiled and installed?"
40
+ end
41
+
42
+ unless system("psql -d #{db_name} -f #{module_location}")
43
+ raise RuntimeError, "`psql -d #{db_name} -f #{module_location}` cannot complete successfully."
44
+ end
45
+ end
46
+
47
+ def install_postgres_91_module(module_name)
48
+ module_location = "#{postgres_share_dir}/extension/#{module_name}.control"
49
+
50
+ unless system("ls #{module_location}")
51
+ raise RuntimeError, "Cannot find the #{module_name} module. Was it compiled and installed?"
52
+ end
53
+
54
+ ActiveRecord::Base.connection.execute("CREATE EXTENSION #{module_name};")
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,10 @@
1
+ # Module used to conform to Rails 3 plugin API
2
+ require File.expand_path(File.dirname(__FILE__) + '/../textacular')
3
+
4
+ module Textacular
5
+ class Railtie < Rails::Railtie
6
+ initializer "textacular.configure_rails_initialization" do
7
+ ActiveRecord::Base.extend(Textacular)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,20 @@
1
+ require 'textacular'
2
+
3
+ def Searchable(*searchable_columns)
4
+ Module.new do
5
+
6
+ include Textacular
7
+
8
+ define_method(:searchable_columns) do
9
+ searchable_columns.map(&:to_s)
10
+ end
11
+
12
+ private :searchable_columns
13
+
14
+ def indexable_columns
15
+ searchable_columns.to_enum
16
+ end
17
+ end
18
+ end
19
+
20
+ Searchable = Textacular
@@ -0,0 +1,18 @@
1
+ require 'rake'
2
+ require 'textacular'
3
+
4
+ namespace :textacular do
5
+ desc 'Create full text search index migration, give the model for which you want to create the indexes'
6
+ task :create_index_migration, [:model_name] => :environment do |task, args|
7
+ raise 'A model name is required' unless args[:model_name]
8
+ Textacular::FullTextIndexer.new.generate_migration(args[:model_name])
9
+ end
10
+
11
+ desc "Install trigram text search module"
12
+ task :install_trigram => [:environment] do
13
+ installer = Textacular::PostgresModuleInstaller.new
14
+ installer.install_module('pg_trgm')
15
+
16
+ puts "Trigram text search module successfully installed into '#{installer.db_name}' database."
17
+ end
18
+ end
@@ -0,0 +1,7 @@
1
+ module Textacular
2
+ VERSION = '3.0.0'
3
+
4
+ def self.version
5
+ VERSION
6
+ end
7
+ end
@@ -0,0 +1,4 @@
1
+ database: textacular
2
+ username: <username>
3
+ pool: 5
4
+ timeout: 5000
File without changes
@@ -0,0 +1,9 @@
1
+ require 'active_record'
2
+
3
+ class Game < ActiveRecord::Base
4
+ # string :system
5
+ # string :title
6
+ # text :description
7
+ end
8
+
9
+ class GameFail < Game; end
@@ -0,0 +1,9 @@
1
+ require 'active_record'
2
+
3
+ class WebComic < ActiveRecord::Base
4
+ # string :name
5
+ # string :author
6
+ # integer :id
7
+
8
+ has_many :characters
9
+ end