airblade-Sphincter 1.1.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.
@@ -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
+