Sphincter 1.0.0

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