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.
- data/CHANGELOG.md +164 -0
- data/Gemfile +3 -0
- data/README.md +143 -0
- data/Rakefile +154 -0
- data/lib/textacular.rb +227 -0
- data/lib/textacular/full_text_indexer.rb +79 -0
- data/lib/textacular/postgres_module_installer.rb +57 -0
- data/lib/textacular/rails.rb +10 -0
- data/lib/textacular/searchable.rb +20 -0
- data/lib/textacular/tasks.rb +18 -0
- data/lib/textacular/version.rb +7 -0
- data/spec/config.yml.example +4 -0
- data/spec/fixtures/character.rb +0 -0
- data/spec/fixtures/game.rb +9 -0
- data/spec/fixtures/webcomic.rb +9 -0
- data/spec/spec_helper.rb +78 -0
- data/spec/textacular/searchable_spec.rb +92 -0
- data/spec/textacular_spec.rb +216 -0
- metadata +178 -0
data/lib/textacular.rb
ADDED
@@ -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
|
File without changes
|