textacular 3.0.0

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