Sphincter 1.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/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
|
+
|