airblade-Sphincter 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,199 @@
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:: Array of fields to index. Foreign key columns for belongs_to
48
+ # associations are automatically added. Fields from associations
49
+ # may be included by using "association.field".
50
+ # :conditions:: Array of SQL conditions that will be ANDed together to
51
+ # predicate inclusion in the search index.
52
+ #
53
+ # Example:
54
+ #
55
+ # class Post < ActiveRecord::Base
56
+ # belongs_to :user
57
+ # belongs_to :blog
58
+ # has_many :comments
59
+ #
60
+ # add_index :fields => %w[title body user.name, comments.body],
61
+ # :conditions => ['published = 1']
62
+ # end
63
+ #
64
+ # When including fields from associations, MySQL's GROUP_CONCAT() function
65
+ # is used. By default this will create a string up to 1024 characters long.
66
+ # A larger string can be used by changing the value of MySQL's
67
+ # group_concat_max_len variable. To do this, add the following to your
68
+ # sphincter.RAILS_ENV.yml files:
69
+ #
70
+ # mysql:
71
+ # sql_query_pre:
72
+ # - SET NAMES utf8
73
+ # - SET SESSION group_concat_max_len = VALUE
74
+
75
+ def add_index(options = {})
76
+ options[:fields] ||= []
77
+
78
+ reflect_on_all_associations.each do |my_assoc|
79
+ next unless my_assoc.macro == :belongs_to
80
+
81
+ options[:fields] << my_assoc.primary_key_name.to_s
82
+
83
+ has_many_klass = my_assoc.class_name.constantize
84
+
85
+ has_many_klass.reflect_on_all_associations.each do |opp_assoc|
86
+ next if opp_assoc.class_name != name or
87
+ opp_assoc.macro != :has_many or
88
+ opp_assoc.options[:conditions]
89
+
90
+ extends = Array(opp_assoc.options[:extend])
91
+ extends << Sphincter::AssociationSearcher
92
+ opp_assoc.options[:extend] = extends
93
+ end
94
+ end
95
+
96
+ options[:fields].uniq!
97
+
98
+ # Use class name rather than object identity to test for hash membership.
99
+ unless Sphincter::Search.indexes.keys.any? {|k| k.class_name == self.class_name}
100
+ Sphincter::Search.indexes[self] << options
101
+ end
102
+ end
103
+
104
+ ##
105
+ # Converts +values+ into an Array of values SetFilter can digest.
106
+ #
107
+ # true/false becomes 1/0, Time/Date/DateTime becomes a time in epoch
108
+ # seconds. Everything else is passed straight through.
109
+
110
+ def sphincter_convert_values(values)
111
+ values.map do |value|
112
+ case value
113
+ when Date, DateTime then Time.parse(value.to_s).to_i
114
+ when FalseClass then 0
115
+ when Time then value.to_i
116
+ when TrueClass then 1
117
+ else value
118
+ end
119
+ end
120
+ end
121
+
122
+ ##
123
+ # Searches for +query+ with +options+.
124
+ #
125
+ # Allowed options are:
126
+ #
127
+ # :between:: Hash of Sphinx range filter conditions. Hash keys are sphinx
128
+ # group_column or date_column names. Values can be
129
+ # Date/Time/DateTime or Integers.
130
+ # :conditions:: Hash of Sphinx value filter conditions. Hash keys are
131
+ # sphinx group_column or date_column names. Values can be a
132
+ # single value or an Array of values.
133
+ # :index:: Name of Sphinx index to search. Defaults to
134
+ # ActiveRecord::Base::table_name.
135
+ # :page:: Page offset of records to return, for easy use with paginators.
136
+ # :per_page:: Size of a page. Default page size is controlled by the
137
+ # configuration.
138
+ #
139
+ # Returns a Sphincter::Search::Results object.
140
+
141
+ def search(query, options = {})
142
+ sphinx = Sphinx::Client.new
143
+
144
+ @host ||= Sphincter::Configure.get_conf['sphincter']['host']
145
+ @port ||= Sphincter::Configure.get_conf['sphincter']['port']
146
+
147
+ sphinx.SetServer @host, @port
148
+
149
+ options[:conditions] ||= {}
150
+ options[:conditions].each do |column, values|
151
+ values = sphincter_convert_values Array(values)
152
+ sphinx.SetFilter column.to_s, values
153
+ end
154
+
155
+ options[:between] ||= {}
156
+ options[:between].each do |column, between|
157
+ min, max = sphincter_convert_values between
158
+
159
+ sphinx.SetFilterRange column.to_s, min, max
160
+ end
161
+
162
+ @default_per_page ||= Sphincter::Configure.get_conf['sphincter']['per_page']
163
+
164
+ per_page = options[:per_page] || @default_per_page
165
+ page_offset = options.key?(:page) ? options[:page] - 1 : 0
166
+ offset = page_offset * per_page
167
+
168
+ sphinx.SetLimits offset, per_page
169
+
170
+ index_name = options[:index] || table_name
171
+
172
+ sphinx_result = sphinx.Query query, index_name
173
+
174
+ matches = sphinx_result['matches'].sort_by do |id, match|
175
+ -match['index'] # #find reverses, lame!
176
+ end
177
+
178
+ ids = matches.map do |id, match|
179
+ (id - match['attrs']['sphincter_index_id']) /
180
+ Sphincter::Configure.index_count
181
+ end
182
+
183
+ results = Results.new
184
+
185
+ results.records = find ids
186
+ results.total = sphinx_result['total_found']
187
+ results.per_page = per_page
188
+
189
+ results
190
+ end
191
+
192
+ end
193
+
194
+ # :stopdoc:
195
+ module ActiveRecord; end
196
+ class ActiveRecord::Base; end
197
+ ActiveRecord::Base.extend Sphincter::Search
198
+ # :startdoc:
199
+
@@ -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,114 @@
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 'Sets up the sphinx client'
49
+ task :setup_sphinx do
50
+ require 'fileutils'
51
+ require 'tmpdir'
52
+ require 'open-uri'
53
+
54
+ verbose = Rake.application.options.trace
55
+
56
+ begin
57
+ tmpdir = File.join Dir.tmpdir, "Sphincter_setup_#{$$}"
58
+
59
+ mkdir tmpdir, :verbose => true
60
+
61
+ chdir tmpdir
62
+
63
+ src = open "http://rubyforge.org/frs/download.php/19571/sphinx-0.3.0.zip"
64
+ File.open('sphinx-0.3.0.zip', 'wb') { |dst| dst.write src.read }
65
+
66
+ quiet = verbose ? '' : ' -q'
67
+ sh "unzip#{quiet} sphinx-0.3.0.zip" or
68
+ raise "couldn't unzip sphinx-0.3.0.zip"
69
+
70
+ File.open 'sphinx.patch', 'wb' do |patch|
71
+ patch.puts <<-EOF
72
+ --- sphinx/lib/client.rb.orig 2007-04-05 06:38:14.000000000 -0700
73
+ +++ sphinx/lib/client.rb 2007-07-29 20:23:18.000000000 -0700
74
+ @@ -398,6 +398,7 @@
75
+ \r
76
+ result['matches'][doc] ||= {}\r
77
+ result['matches'][doc]['weight'] = weight\r
78
+ + result['matches'][doc]['index'] = count\r
79
+ attrs_names_in_order.each do |attr|\r
80
+ val = response[p, 4].unpack('N*').first; p += 4\r
81
+ result['matches'][doc]['attrs'] ||= {}\r
82
+ EOF
83
+ end
84
+
85
+ quiet = verbose ? ' --verbose' : ''
86
+ sh "patch#{quiet} -p0 sphinx/lib/client.rb sphinx.patch" or
87
+ raise "couldn't patch sphinx"
88
+
89
+ sphinx_plugin_dir = File.join RAILS_ROOT, 'vendor', 'plugins', 'sphinx'
90
+ rm_rf sphinx_plugin_dir, :verbose => true
91
+
92
+ mv 'sphinx', sphinx_plugin_dir, :verbose => true
93
+ ensure
94
+ rm_rf tmpdir, :verbose => true
95
+ end
96
+ end
97
+
98
+ desc 'Starts the searchd sphinx daemon'
99
+ task :start_searchd => :index do
100
+ unless Sphincter::Configure.searchd_running? then
101
+ cmd = "searchd --config #{Sphincter::Configure.sphinx_conf}"
102
+ cmd << " > /dev/null" unless Rake.application.options.trace
103
+ system cmd
104
+ end
105
+ end
106
+
107
+ desc 'Stops the searchd daemon'
108
+ task :stop_searchd => :configure do
109
+ pid = Sphincter::Configure.searchd_running?
110
+ system 'kill', pid if pid
111
+ end
112
+
113
+ end
114
+
@@ -0,0 +1,220 @@
1
+ require 'test/unit'
2
+ require 'fileutils'
3
+ require 'tmpdir'
4
+
5
+ $TESTING = true
6
+
7
+ class String
8
+ def constantize
9
+ case self
10
+ when /belongs_to/i then SphincterTestCase::BelongsTo
11
+ when /many/i then SphincterTestCase::HasMany
12
+ when /poly/i then SphincterTestCase::Poly
13
+ else raise "missing klass for #{self} in #constantize"
14
+ end
15
+ end
16
+ end
17
+
18
+ require 'sphincter'
19
+
20
+ class ActiveRecord::Base
21
+ def self.configurations
22
+ {
23
+ 'development' => {
24
+ 'adapter' => 'mysql',
25
+ 'host' => 'host',
26
+ 'username' => 'username',
27
+ 'password' => 'password',
28
+ 'database' => 'database',
29
+ 'socket' => 'socket',
30
+ }
31
+ }
32
+ end
33
+ end
34
+
35
+ module Sphinx; end
36
+
37
+ class Sphinx::Client
38
+
39
+ @@last_client = nil
40
+
41
+ attr_reader :host, :port, :query, :index, :filters, :offset, :limit
42
+
43
+ def self.last_client
44
+ @@last_client
45
+ end
46
+
47
+ def initialize
48
+ @filters = {}
49
+
50
+ @@last_client = self
51
+ end
52
+
53
+ def Query(query, index)
54
+ @query, @index = query, index
55
+
56
+ {
57
+ 'matches' => {
58
+ 12 => { 'attrs' => { 'sphincter_index_id' => 1 }, 'index' => 3 },
59
+ 13 => { 'attrs' => { 'sphincter_index_id' => 1 }, 'index' => 1 },
60
+ 14 => { 'attrs' => { 'sphincter_index_id' => 1 }, 'index' => 2 },
61
+ },
62
+ 'total_found' => 3
63
+ }
64
+ end
65
+
66
+ def SetFilter(column, values)
67
+ @filters[column] = values
68
+ end
69
+
70
+ def SetFilterRange(column, min, max)
71
+ @filters[column] = [:range, min, max]
72
+ end
73
+
74
+ def SetLimits(offset, limit)
75
+ @offset, @limit = offset, limit
76
+ end
77
+
78
+ def SetServer(host, port)
79
+ @host, @port = host, port
80
+ end
81
+
82
+ end
83
+
84
+ class SphincterTestCase < Test::Unit::TestCase
85
+
86
+ undef_method :default_test
87
+
88
+ class Column
89
+ attr_accessor :type
90
+
91
+ def initialize(type)
92
+ @type = type
93
+ end
94
+ end
95
+
96
+ class Connection
97
+ def quote(name) "'#{name}'" end
98
+ def quote_column_name(name) "`#{name}`" end
99
+ end
100
+
101
+ class Reflection
102
+ attr_accessor :klass
103
+ attr_reader :macro, :options, :name
104
+
105
+ def initialize(macro, name, options = {})
106
+ @klass = Model
107
+ @macro = macro
108
+ @name = name.intern
109
+ @options = options
110
+ end
111
+
112
+ def class_name() @name.to_s.sub(/s$/, '').capitalize end
113
+ def primary_key_name() "#{@name}_id" end
114
+ end
115
+
116
+ class Model
117
+
118
+ @reflections = [Reflection.new(:belongs_to, 'belongs_to'),
119
+ Reflection.new(:has_many, 'manys'),
120
+ Reflection.new(:has_many, 'polys', :as => :polyable)]
121
+
122
+ class << self; attr_accessor :reflections; end
123
+
124
+ def self.connection() Connection.new end
125
+
126
+ def self.columns_hash
127
+ {
128
+ 'boolean' => Column.new(:boolean),
129
+ 'date' => Column.new(:date),
130
+ 'datetime' => Column.new(:datetime),
131
+ 'float' => Column.new(:float),
132
+ 'integer' => Column.new(:integer),
133
+ 'string' => Column.new(:string),
134
+ 'text' => Column.new(:text),
135
+ 'time' => Column.new(:time),
136
+ 'timestamp' => Column.new(:timestamp),
137
+ }
138
+ end
139
+
140
+ def self.find(ids) ids end
141
+
142
+ def self.name() 'Model' end
143
+
144
+ def self.primary_key() 'id' end
145
+
146
+ def self.reflect_on_all_associations
147
+ @reflections
148
+ end
149
+
150
+ def self.table_name() 'models' end
151
+
152
+ end
153
+
154
+ class BelongsTo < Model
155
+ @reflections = [Reflection.new(:belongs_to, 'something'),
156
+ Reflection.new(:has_many, 'models')]
157
+
158
+ def self.table_name() 'belongs_tos' end
159
+
160
+ def id() 42 end
161
+ end
162
+
163
+ class HasMany < Model
164
+ @reflections = [Reflection.new(:belongs_to, 'models')]
165
+
166
+ def self.table_name() 'has_manys' end
167
+
168
+ def id() 84 end
169
+ end
170
+
171
+ class Poly < Model
172
+ @reflections = [Reflection.new(:belongs_to, 'polyable',
173
+ :polymorphic => true)]
174
+
175
+ def self.table_name() 'polys' end
176
+
177
+ def id() 126 end
178
+ end
179
+
180
+ class Model
181
+ extend Sphincter::Search
182
+ end
183
+
184
+ def setup
185
+ @temp_dir = File.join Dir.tmpdir, "sphincter_test_case_#{$$}"
186
+ FileUtils.mkdir_p @temp_dir
187
+ @orig_dir = Dir.pwd
188
+ Dir.chdir @temp_dir
189
+
190
+ @old_RAILS_ROOT = (Object.const_get :RAILS_ROOT rescue nil)
191
+ Object.send :remove_const, :RAILS_ROOT rescue nil
192
+ Object.const_set :RAILS_ROOT, @temp_dir
193
+
194
+ @old_RAILS_ENV = (Object.const_get :RAILS_ENV rescue nil)
195
+ Object.send :remove_const, :RAILS_ENV rescue nil
196
+ Object.const_set :RAILS_ENV, 'development'
197
+
198
+ Sphincter::Search.indexes.replace Hash.new { |h,k| h[k] = [] }
199
+
200
+ Sphincter::Configure.instance_variable_set '@env_conf', nil if
201
+ Sphincter::Configure.instance_variables.include? '@env_conf'
202
+ Sphincter::Configure.instance_variable_set '@index_count', nil if
203
+ Sphincter::Configure.instance_variables.include? '@index_count'
204
+
205
+ BelongsTo.reflections.last.options.delete :extend
206
+ end
207
+
208
+ def teardown
209
+ FileUtils.rm_rf @temp_dir
210
+ Dir.chdir @orig_dir
211
+
212
+ Object.send :remove_const, :RAILS_ROOT
213
+ Object.const_set :RAILS_ROOT, @old_RAILS_ROOT
214
+
215
+ Object.send :remove_const, :RAILS_ENV
216
+ Object.const_set :RAILS_ENV, @old_RAILS_ENV
217
+ end
218
+
219
+ end
220
+