Sphincter 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/History.txt +5 -0
- data/LICENSE.txt +27 -0
- data/Manifest.txt +16 -0
- data/README.txt +132 -0
- data/Rakefile +21 -0
- data/lib/sphincter.rb +102 -0
- data/lib/sphincter/association_searcher.rb +22 -0
- data/lib/sphincter/configure.rb +380 -0
- data/lib/sphincter/search.rb +173 -0
- data/lib/sphincter/search_stub.rb +60 -0
- data/lib/sphincter/tasks.rb +64 -0
- data/test/sphincter_test_case.rb +190 -0
- data/test/test_sphincter_association_searcher.rb +39 -0
- data/test/test_sphincter_configure.rb +318 -0
- data/test/test_sphincter_search.rb +100 -0
- data/test/test_sphincter_search_stub.rb +50 -0
- metadata +100 -0
@@ -0,0 +1,173 @@
|
|
1
|
+
require 'date'
|
2
|
+
require 'time'
|
3
|
+
require 'sphincter'
|
4
|
+
|
5
|
+
##
|
6
|
+
# Search extension for ActiveRecord::Base that automatically extends
|
7
|
+
# ActiveRecord::Base.
|
8
|
+
|
9
|
+
module Sphincter::Search
|
10
|
+
|
11
|
+
##
|
12
|
+
# Struct to hold search results.
|
13
|
+
#
|
14
|
+
# records:: ActiveRecord objects returned by sphinx
|
15
|
+
# total:: Total records searched
|
16
|
+
# per_page:: Size of each chunk of records
|
17
|
+
|
18
|
+
Results = Struct.new :records, :total, :per_page
|
19
|
+
|
20
|
+
##
|
21
|
+
# Indexes registered with add_index.
|
22
|
+
|
23
|
+
@@indexes ||= Hash.new { |h, model| h[model] = [] }
|
24
|
+
|
25
|
+
##
|
26
|
+
# Accessor for indexes registered with add_index.
|
27
|
+
|
28
|
+
def self.indexes
|
29
|
+
@@indexes
|
30
|
+
end
|
31
|
+
|
32
|
+
##
|
33
|
+
# Adds an index with +options+.
|
34
|
+
#
|
35
|
+
# add_index will automatically add Sphincter::AssociationSearcher to any
|
36
|
+
# has_many associations referenced by this model's belongs_to associations.
|
37
|
+
# If this model belongs_to another model, and that model has_many of this
|
38
|
+
# model, then you will be able to <tt>other.models.search</tt> and recieve
|
39
|
+
# only records in the association.
|
40
|
+
#
|
41
|
+
# Currently, only has_many associations without conditions will have
|
42
|
+
# AssociationSearcher appended.
|
43
|
+
#
|
44
|
+
# Options are:
|
45
|
+
#
|
46
|
+
# :name:: Name of index. Defaults to ActiveRecord::Base::table_name.
|
47
|
+
# :fields:: Fields to index. Columns from belongs_to associations are
|
48
|
+
# automatically added.
|
49
|
+
# :conditions:: Hash of SQL conditions that predicate inclusion in the
|
50
|
+
# search index.
|
51
|
+
|
52
|
+
def add_index(options = {})
|
53
|
+
options[:fields] ||= []
|
54
|
+
|
55
|
+
reflect_on_all_associations.each do |my_assoc|
|
56
|
+
next unless my_assoc.macro == :belongs_to
|
57
|
+
|
58
|
+
options[:fields] << my_assoc.primary_key_name.to_s
|
59
|
+
|
60
|
+
has_many_klass = my_assoc.class_name.constantize
|
61
|
+
|
62
|
+
has_many_klass.reflect_on_all_associations.each do |opp_assoc|
|
63
|
+
next if opp_assoc.class_name != name or
|
64
|
+
opp_assoc.macro != :has_many or
|
65
|
+
opp_assoc.options[:conditions]
|
66
|
+
|
67
|
+
extends = Array(opp_assoc.options[:extend])
|
68
|
+
extends << Sphincter::AssociationSearcher
|
69
|
+
opp_assoc.options[:extend] = extends
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
options[:fields].uniq!
|
74
|
+
|
75
|
+
Sphincter::Search.indexes[self] << options
|
76
|
+
end
|
77
|
+
|
78
|
+
##
|
79
|
+
# Converts +values+ into an Array of values SetFilter can digest.
|
80
|
+
#
|
81
|
+
# true/false becomes 1/0, Time/Date/DateTime becomes a time in epoch
|
82
|
+
# seconds. Everything else is passed straight through.
|
83
|
+
|
84
|
+
def sphincter_convert_values(values)
|
85
|
+
values.map do |value|
|
86
|
+
case value
|
87
|
+
when Date, DateTime then Time.parse(value.to_s).to_i
|
88
|
+
when FalseClass then 0
|
89
|
+
when Time then value.to_i
|
90
|
+
when TrueClass then 1
|
91
|
+
else value
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
##
|
97
|
+
# Searches for +query+ with +options+.
|
98
|
+
#
|
99
|
+
# Allowed options are:
|
100
|
+
#
|
101
|
+
# :between:: Hash of Sphinx range filter conditions. Hash keys are sphinx
|
102
|
+
# group_column or date_column names. Values can be
|
103
|
+
# Date/Time/DateTime or Integers.
|
104
|
+
# :conditions:: Hash of Sphinx value filter conditions. Hash keys are
|
105
|
+
# sphinx group_column or date_column names. Values can be a
|
106
|
+
# single value or an Array of values.
|
107
|
+
# :index:: Name of Sphinx index to search. Defaults to
|
108
|
+
# ActiveRecord::Base::table_name.
|
109
|
+
# :page:: Page offset of records to return, for easy use with paginators.
|
110
|
+
# :per_page:: Size of a page. Default page size is controlled by the
|
111
|
+
# configuration.
|
112
|
+
#
|
113
|
+
# Returns a Sphincter::Search::Results object.
|
114
|
+
|
115
|
+
def search(query, options = {})
|
116
|
+
sphinx = Sphinx::Client.new
|
117
|
+
|
118
|
+
@host ||= Sphincter::Configure.get_conf['sphincter']['host']
|
119
|
+
@port ||= Sphincter::Configure.get_conf['sphincter']['port']
|
120
|
+
|
121
|
+
sphinx.SetServer @host, @port
|
122
|
+
|
123
|
+
options[:conditions] ||= {}
|
124
|
+
options[:conditions].each do |column, values|
|
125
|
+
values = sphincter_convert_values Array(values)
|
126
|
+
sphinx.SetFilter column.to_s, values
|
127
|
+
end
|
128
|
+
|
129
|
+
options[:between] ||= {}
|
130
|
+
options[:between].each do |column, between|
|
131
|
+
min, max = sphincter_convert_values between
|
132
|
+
|
133
|
+
sphinx.SetFilterRange column.to_s, min, max
|
134
|
+
end
|
135
|
+
|
136
|
+
@default_per_page ||= Sphincter::Configure.get_conf['sphincter']['per_page']
|
137
|
+
|
138
|
+
per_page = options[:per_page] || @default_per_page
|
139
|
+
page_offset = options.key?(:page) ? options[:page] - 1 : 0
|
140
|
+
offset = page_offset * per_page
|
141
|
+
|
142
|
+
sphinx.SetLimits offset, per_page
|
143
|
+
|
144
|
+
index_name = options[:index] || table_name
|
145
|
+
|
146
|
+
sphinx_result = sphinx.Query query, index_name
|
147
|
+
|
148
|
+
matches = sphinx_result['matches'].sort_by do |id, match|
|
149
|
+
-match['index'] # #find reverses, lame!
|
150
|
+
end
|
151
|
+
|
152
|
+
ids = matches.map do |id, match|
|
153
|
+
(id - match['attrs']['sphincter_index_id']) /
|
154
|
+
Sphincter::Configure.index_count
|
155
|
+
end
|
156
|
+
|
157
|
+
results = Results.new
|
158
|
+
|
159
|
+
results.records = find ids
|
160
|
+
results.total = sphinx_result['total_found']
|
161
|
+
results.per_page = per_page
|
162
|
+
|
163
|
+
results
|
164
|
+
end
|
165
|
+
|
166
|
+
end
|
167
|
+
|
168
|
+
# :stopdoc:
|
169
|
+
module ActiveRecord; end
|
170
|
+
class ActiveRecord::Base; end
|
171
|
+
ActiveRecord::Base.extend Sphincter::Search
|
172
|
+
# :startdoc:
|
173
|
+
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require 'sphincter'
|
2
|
+
|
3
|
+
##
|
4
|
+
# Stub for Sphincter searching. Extend ActiveRecord::Base with this module in
|
5
|
+
# your tests so you won't have to run a Sphinx searchd to test your searching.
|
6
|
+
#
|
7
|
+
# In test/testHelper.rb:
|
8
|
+
#
|
9
|
+
# require 'sphincter/search_stub'
|
10
|
+
# ActiveRecord::Base.extend Sphincter::SearchStub
|
11
|
+
#
|
12
|
+
# Before running a search, you'll need to populate the stub's accessors:
|
13
|
+
#
|
14
|
+
# def test_search
|
15
|
+
# Model.search_args = []
|
16
|
+
# Model.search_results = [Model.find(1, 2, 3)]
|
17
|
+
#
|
18
|
+
# records = Model.search 'query'
|
19
|
+
#
|
20
|
+
# assert_equal 1, Model.search_args.length
|
21
|
+
# assert_equal [...], Model.search_args
|
22
|
+
# assert_equal 0, Model.search_results.length
|
23
|
+
# end
|
24
|
+
#
|
25
|
+
# Since both search_args and search_results are an Array you can call #search
|
26
|
+
# multiple times and get back different results per call. #search will raise
|
27
|
+
# an exception if you don't supply enough results.
|
28
|
+
|
29
|
+
module Sphincter::SearchStub
|
30
|
+
|
31
|
+
##
|
32
|
+
# An Array that records arguments #search was called with. search_args
|
33
|
+
# isn't set to anything by default, so do that in your test setup.
|
34
|
+
|
35
|
+
attr_accessor :search_args
|
36
|
+
|
37
|
+
##
|
38
|
+
# A pre-populated Array of search results for queries. search_results isn't
|
39
|
+
# set to anything by default, so do that in your test setup.
|
40
|
+
|
41
|
+
attr_accessor :search_results
|
42
|
+
|
43
|
+
##
|
44
|
+
# Overrides Sphincter::Search#search to use the search_args and
|
45
|
+
# search_results values instead of connecting to Sphinx.
|
46
|
+
|
47
|
+
def search(query, options = {})
|
48
|
+
unless @search_args and @search_results then
|
49
|
+
raise 'need to set up Sphincter::SearchStub#search_results and/or Sphincter::SearchStub#search_args in test setup'
|
50
|
+
end
|
51
|
+
|
52
|
+
@search_args << [query, options]
|
53
|
+
|
54
|
+
raise 'no more search results in search stub' if @search_results.empty?
|
55
|
+
|
56
|
+
@search_results.shift
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
60
|
+
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'rake'
|
2
|
+
require 'sphincter/configure'
|
3
|
+
|
4
|
+
namespace :sphincter do
|
5
|
+
|
6
|
+
desc 'Creates sphinx.conf if it doesn\'t exist'
|
7
|
+
task :configure => :environment do
|
8
|
+
sphinx_conf = Sphincter::Configure.sphinx_conf
|
9
|
+
Rake::Task['sphincter:reconfigure'].invoke unless File.exist? sphinx_conf
|
10
|
+
end
|
11
|
+
|
12
|
+
desc 'Creates sphinx.conf'
|
13
|
+
task :reconfigure => :environment do
|
14
|
+
Sphincter::Configure.configure
|
15
|
+
end
|
16
|
+
|
17
|
+
desc 'Runs the sphinx indexer if the indexes don\'t exist'
|
18
|
+
task :index => :configure do
|
19
|
+
indexes_defined = Sphincter::Configure.index_count
|
20
|
+
sphinx_dir = Sphincter::Configure.get_conf['sphincter']['path']
|
21
|
+
|
22
|
+
indexes_found = Dir[File.join(sphinx_dir, '*.spd')].length
|
23
|
+
|
24
|
+
Rake::Task['sphincter::reindex'].invoke if indexes_found > indexes_defined
|
25
|
+
end
|
26
|
+
|
27
|
+
desc 'Runs the sphinx indexer'
|
28
|
+
task :reindex => :configure do
|
29
|
+
sphinx_conf = Sphincter::Configure.sphinx_conf
|
30
|
+
cmd = %W[indexer --all --config #{sphinx_conf}]
|
31
|
+
cmd << "--quiet" unless Rake.application.options.trace
|
32
|
+
cmd << "--rotate" if Sphincter::Configure.searchd_running?
|
33
|
+
system(*cmd)
|
34
|
+
end
|
35
|
+
|
36
|
+
desc 'Stops searchd, reconfigures and reindexes'
|
37
|
+
task :reset => :configure do
|
38
|
+
Rake::Task['sphincter:stop_searchd'].invoke
|
39
|
+
FileUtils.rm_rf Sphincter::Configure.sphinx_dir,
|
40
|
+
:verbose => Rake.application.options.trace
|
41
|
+
Rake::Task['sphincter:reconfigure'].execute # force reindex
|
42
|
+
Rake::Task['sphincter:reindex'].invoke
|
43
|
+
end
|
44
|
+
|
45
|
+
desc 'Restarts the searchd sphinx daemon'
|
46
|
+
task :restart_searchd => %w[sphincter:stop_searchd sphincter:start_searchd]
|
47
|
+
|
48
|
+
desc 'Starts the searchd sphinx daemon'
|
49
|
+
task :start_searchd => :index do
|
50
|
+
unless Sphincter::Configure.searchd_running? then
|
51
|
+
cmd = "searchd --config #{Sphincter::Configure.sphinx_conf}"
|
52
|
+
cmd << " > /dev/null" unless Rake.application.options.trace
|
53
|
+
system cmd
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
desc 'Stops the searchd daemon'
|
58
|
+
task :stop_searchd => :configure do
|
59
|
+
pid = Sphincter::Configure.searchd_running?
|
60
|
+
system 'kill', pid if pid
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
64
|
+
|
@@ -0,0 +1,190 @@
|
|
1
|
+
require 'test/unit'
|
2
|
+
require 'fileutils'
|
3
|
+
require 'tmpdir'
|
4
|
+
|
5
|
+
$TESTING = true
|
6
|
+
|
7
|
+
class String
|
8
|
+
def constantize() SphincterTestCase::Other end
|
9
|
+
end
|
10
|
+
|
11
|
+
require 'sphincter'
|
12
|
+
|
13
|
+
class ActiveRecord::Base
|
14
|
+
def self.configurations
|
15
|
+
{
|
16
|
+
'development' => {
|
17
|
+
'adapter' => 'mysql',
|
18
|
+
'host' => 'host',
|
19
|
+
'username' => 'username',
|
20
|
+
'password' => 'password',
|
21
|
+
'database' => 'database',
|
22
|
+
'socket' => 'socket',
|
23
|
+
}
|
24
|
+
}
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
module Sphinx; end
|
29
|
+
|
30
|
+
class Sphinx::Client
|
31
|
+
|
32
|
+
@@last_client = nil
|
33
|
+
|
34
|
+
attr_reader :host, :port, :query, :index, :filters, :offset, :limit
|
35
|
+
|
36
|
+
def self.last_client
|
37
|
+
@@last_client
|
38
|
+
end
|
39
|
+
|
40
|
+
def initialize
|
41
|
+
@filters = {}
|
42
|
+
|
43
|
+
@@last_client = self
|
44
|
+
end
|
45
|
+
|
46
|
+
def Query(query, index)
|
47
|
+
@query, @index = query, index
|
48
|
+
|
49
|
+
{
|
50
|
+
'matches' => {
|
51
|
+
12 => { 'attrs' => { 'sphincter_index_id' => 1 }, 'index' => 3 },
|
52
|
+
13 => { 'attrs' => { 'sphincter_index_id' => 1 }, 'index' => 1 },
|
53
|
+
14 => { 'attrs' => { 'sphincter_index_id' => 1 }, 'index' => 2 },
|
54
|
+
},
|
55
|
+
'total_found' => 3
|
56
|
+
}
|
57
|
+
end
|
58
|
+
|
59
|
+
def SetFilter(column, values)
|
60
|
+
@filters[column] = values
|
61
|
+
end
|
62
|
+
|
63
|
+
def SetFilterRange(column, min, max)
|
64
|
+
@filters[column] = [:range, min, max]
|
65
|
+
end
|
66
|
+
|
67
|
+
def SetLimits(offset, limit)
|
68
|
+
@offset, @limit = offset, limit
|
69
|
+
end
|
70
|
+
|
71
|
+
def SetServer(host, port)
|
72
|
+
@host, @port = host, port
|
73
|
+
end
|
74
|
+
|
75
|
+
end
|
76
|
+
|
77
|
+
class SphincterTestCase < Test::Unit::TestCase
|
78
|
+
|
79
|
+
undef_method :default_test
|
80
|
+
|
81
|
+
class Column
|
82
|
+
attr_accessor :type
|
83
|
+
|
84
|
+
def initialize(type)
|
85
|
+
@type = type
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
class Connection
|
90
|
+
def quote(name) "`#{name}`" end
|
91
|
+
def quote_column_name(name) "`#{name}`" end
|
92
|
+
end
|
93
|
+
|
94
|
+
class Reflection
|
95
|
+
attr_accessor :klass
|
96
|
+
attr_reader :macro, :options
|
97
|
+
|
98
|
+
def initialize(macro, name)
|
99
|
+
@klass = Model
|
100
|
+
@macro = macro
|
101
|
+
@name = name
|
102
|
+
@options = {}
|
103
|
+
end
|
104
|
+
|
105
|
+
def class_name() "SphincterTestCase::#{@name.capitalize}" end
|
106
|
+
def primary_key_name() "#{@name}_id" end
|
107
|
+
end
|
108
|
+
|
109
|
+
class Model
|
110
|
+
|
111
|
+
@reflections = [Reflection.new(:belongs_to, 'other'),
|
112
|
+
Reflection.new(:has_many, 'other')]
|
113
|
+
|
114
|
+
class << self; attr_accessor :reflections; end
|
115
|
+
|
116
|
+
def self.connection() Connection.new end
|
117
|
+
|
118
|
+
def self.columns_hash
|
119
|
+
{
|
120
|
+
'boolean' => Column.new(:boolean),
|
121
|
+
'date' => Column.new(:date),
|
122
|
+
'datetime' => Column.new(:datetime),
|
123
|
+
'integer' => Column.new(:integer),
|
124
|
+
'string' => Column.new(:string),
|
125
|
+
'text' => Column.new(:text),
|
126
|
+
'time' => Column.new(:time),
|
127
|
+
'timestamp' => Column.new(:timestamp),
|
128
|
+
}
|
129
|
+
end
|
130
|
+
|
131
|
+
def self.find(ids) ids end
|
132
|
+
|
133
|
+
def self.primary_key() 'id' end
|
134
|
+
|
135
|
+
def self.reflect_on_all_associations
|
136
|
+
@reflections
|
137
|
+
end
|
138
|
+
|
139
|
+
def self.table_name() 'models' end
|
140
|
+
|
141
|
+
end
|
142
|
+
|
143
|
+
class Other < Model
|
144
|
+
@reflections = [Reflection.new(:belongs_to, 'model'),
|
145
|
+
Reflection.new(:has_many, 'model')]
|
146
|
+
|
147
|
+
def id() 42 end
|
148
|
+
end
|
149
|
+
|
150
|
+
class Model
|
151
|
+
extend Sphincter::Search
|
152
|
+
end
|
153
|
+
|
154
|
+
def setup
|
155
|
+
@temp_dir = File.join Dir.tmpdir, "sphincter_test_case_#{$$}"
|
156
|
+
FileUtils.mkdir_p @temp_dir
|
157
|
+
@orig_dir = Dir.pwd
|
158
|
+
Dir.chdir @temp_dir
|
159
|
+
|
160
|
+
@old_RAILS_ROOT = (Object.const_get :RAILS_ROOT rescue nil)
|
161
|
+
Object.send :remove_const, :RAILS_ROOT rescue nil
|
162
|
+
Object.const_set :RAILS_ROOT, @temp_dir
|
163
|
+
|
164
|
+
@old_RAILS_ENV = (Object.const_get :RAILS_ENV rescue nil)
|
165
|
+
Object.send :remove_const, :RAILS_ENV rescue nil
|
166
|
+
Object.const_set :RAILS_ENV, 'development'
|
167
|
+
|
168
|
+
Sphincter::Search.indexes.replace Hash.new { |h,k| h[k] = [] }
|
169
|
+
|
170
|
+
Sphincter::Configure.instance_variable_set '@env_conf', nil if
|
171
|
+
Sphincter::Configure.instance_variables.include? '@env_conf'
|
172
|
+
Sphincter::Configure.instance_variable_set '@index_count', nil if
|
173
|
+
Sphincter::Configure.instance_variables.include? '@index_count'
|
174
|
+
|
175
|
+
Other.reflections.last.options.delete :extend
|
176
|
+
end
|
177
|
+
|
178
|
+
def teardown
|
179
|
+
FileUtils.rm_rf @temp_dir
|
180
|
+
Dir.chdir @orig_dir
|
181
|
+
|
182
|
+
Object.send :remove_const, :RAILS_ROOT
|
183
|
+
Object.const_set :RAILS_ROOT, @old_RAILS_ROOT
|
184
|
+
|
185
|
+
Object.send :remove_const, :RAILS_ENV
|
186
|
+
Object.const_set :RAILS_ENV, @old_RAILS_ENV
|
187
|
+
end
|
188
|
+
|
189
|
+
end
|
190
|
+
|