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.
@@ -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
+