thinking-sphinx 1.2.12

Sign up to get free protection for your applications and to get access to all the features.
Files changed (95) hide show
  1. data/LICENCE +20 -0
  2. data/README.textile +157 -0
  3. data/VERSION.yml +4 -0
  4. data/lib/thinking_sphinx.rb +211 -0
  5. data/lib/thinking_sphinx/active_record.rb +307 -0
  6. data/lib/thinking_sphinx/active_record/attribute_updates.rb +48 -0
  7. data/lib/thinking_sphinx/active_record/delta.rb +87 -0
  8. data/lib/thinking_sphinx/active_record/has_many_association.rb +28 -0
  9. data/lib/thinking_sphinx/active_record/scopes.rb +39 -0
  10. data/lib/thinking_sphinx/adapters/abstract_adapter.rb +42 -0
  11. data/lib/thinking_sphinx/adapters/mysql_adapter.rb +54 -0
  12. data/lib/thinking_sphinx/adapters/postgresql_adapter.rb +136 -0
  13. data/lib/thinking_sphinx/association.rb +164 -0
  14. data/lib/thinking_sphinx/attribute.rb +342 -0
  15. data/lib/thinking_sphinx/class_facet.rb +15 -0
  16. data/lib/thinking_sphinx/configuration.rb +282 -0
  17. data/lib/thinking_sphinx/core/array.rb +7 -0
  18. data/lib/thinking_sphinx/core/string.rb +15 -0
  19. data/lib/thinking_sphinx/deltas.rb +30 -0
  20. data/lib/thinking_sphinx/deltas/datetime_delta.rb +50 -0
  21. data/lib/thinking_sphinx/deltas/default_delta.rb +68 -0
  22. data/lib/thinking_sphinx/deltas/delayed_delta.rb +30 -0
  23. data/lib/thinking_sphinx/deltas/delayed_delta/delta_job.rb +24 -0
  24. data/lib/thinking_sphinx/deltas/delayed_delta/flag_as_deleted_job.rb +27 -0
  25. data/lib/thinking_sphinx/deltas/delayed_delta/job.rb +26 -0
  26. data/lib/thinking_sphinx/deploy/capistrano.rb +100 -0
  27. data/lib/thinking_sphinx/excerpter.rb +22 -0
  28. data/lib/thinking_sphinx/facet.rb +125 -0
  29. data/lib/thinking_sphinx/facet_search.rb +134 -0
  30. data/lib/thinking_sphinx/field.rb +82 -0
  31. data/lib/thinking_sphinx/index.rb +99 -0
  32. data/lib/thinking_sphinx/index/builder.rb +286 -0
  33. data/lib/thinking_sphinx/index/faux_column.rb +110 -0
  34. data/lib/thinking_sphinx/property.rb +162 -0
  35. data/lib/thinking_sphinx/rails_additions.rb +150 -0
  36. data/lib/thinking_sphinx/search.rb +707 -0
  37. data/lib/thinking_sphinx/search_methods.rb +421 -0
  38. data/lib/thinking_sphinx/source.rb +150 -0
  39. data/lib/thinking_sphinx/source/internal_properties.rb +46 -0
  40. data/lib/thinking_sphinx/source/sql.rb +128 -0
  41. data/lib/thinking_sphinx/tasks.rb +165 -0
  42. data/rails/init.rb +14 -0
  43. data/spec/lib/thinking_sphinx/active_record/delta_spec.rb +130 -0
  44. data/spec/lib/thinking_sphinx/active_record/has_many_association_spec.rb +49 -0
  45. data/spec/lib/thinking_sphinx/active_record/scopes_spec.rb +96 -0
  46. data/spec/lib/thinking_sphinx/active_record_spec.rb +364 -0
  47. data/spec/lib/thinking_sphinx/association_spec.rb +239 -0
  48. data/spec/lib/thinking_sphinx/attribute_spec.rb +500 -0
  49. data/spec/lib/thinking_sphinx/configuration_spec.rb +268 -0
  50. data/spec/lib/thinking_sphinx/core/array_spec.rb +9 -0
  51. data/spec/lib/thinking_sphinx/core/string_spec.rb +9 -0
  52. data/spec/lib/thinking_sphinx/excerpter_spec.rb +49 -0
  53. data/spec/lib/thinking_sphinx/facet_search_spec.rb +176 -0
  54. data/spec/lib/thinking_sphinx/facet_spec.rb +333 -0
  55. data/spec/lib/thinking_sphinx/field_spec.rb +154 -0
  56. data/spec/lib/thinking_sphinx/index/builder_spec.rb +455 -0
  57. data/spec/lib/thinking_sphinx/index/faux_column_spec.rb +30 -0
  58. data/spec/lib/thinking_sphinx/index_spec.rb +45 -0
  59. data/spec/lib/thinking_sphinx/rails_additions_spec.rb +203 -0
  60. data/spec/lib/thinking_sphinx/search_methods_spec.rb +152 -0
  61. data/spec/lib/thinking_sphinx/search_spec.rb +1092 -0
  62. data/spec/lib/thinking_sphinx/source_spec.rb +227 -0
  63. data/spec/lib/thinking_sphinx_spec.rb +162 -0
  64. data/tasks/distribution.rb +50 -0
  65. data/tasks/rails.rake +1 -0
  66. data/tasks/testing.rb +83 -0
  67. data/vendor/after_commit/LICENSE +20 -0
  68. data/vendor/after_commit/README +16 -0
  69. data/vendor/after_commit/Rakefile +22 -0
  70. data/vendor/after_commit/init.rb +8 -0
  71. data/vendor/after_commit/lib/after_commit.rb +45 -0
  72. data/vendor/after_commit/lib/after_commit/active_record.rb +114 -0
  73. data/vendor/after_commit/lib/after_commit/connection_adapters.rb +103 -0
  74. data/vendor/after_commit/test/after_commit_test.rb +53 -0
  75. data/vendor/delayed_job/lib/delayed/job.rb +251 -0
  76. data/vendor/delayed_job/lib/delayed/message_sending.rb +7 -0
  77. data/vendor/delayed_job/lib/delayed/performable_method.rb +55 -0
  78. data/vendor/delayed_job/lib/delayed/worker.rb +54 -0
  79. data/vendor/riddle/lib/riddle.rb +30 -0
  80. data/vendor/riddle/lib/riddle/client.rb +635 -0
  81. data/vendor/riddle/lib/riddle/client/filter.rb +53 -0
  82. data/vendor/riddle/lib/riddle/client/message.rb +66 -0
  83. data/vendor/riddle/lib/riddle/client/response.rb +84 -0
  84. data/vendor/riddle/lib/riddle/configuration.rb +33 -0
  85. data/vendor/riddle/lib/riddle/configuration/distributed_index.rb +48 -0
  86. data/vendor/riddle/lib/riddle/configuration/index.rb +142 -0
  87. data/vendor/riddle/lib/riddle/configuration/indexer.rb +19 -0
  88. data/vendor/riddle/lib/riddle/configuration/remote_index.rb +17 -0
  89. data/vendor/riddle/lib/riddle/configuration/searchd.rb +25 -0
  90. data/vendor/riddle/lib/riddle/configuration/section.rb +43 -0
  91. data/vendor/riddle/lib/riddle/configuration/source.rb +23 -0
  92. data/vendor/riddle/lib/riddle/configuration/sql_source.rb +34 -0
  93. data/vendor/riddle/lib/riddle/configuration/xml_source.rb +28 -0
  94. data/vendor/riddle/lib/riddle/controller.rb +53 -0
  95. metadata +172 -0
@@ -0,0 +1,110 @@
1
+ module ThinkingSphinx
2
+ class Index
3
+ # Instances of this class represent database columns and the stack of
4
+ # associations that lead from the base model to them.
5
+ #
6
+ # The name and stack are accessible through methods starting with __ to
7
+ # avoid conflicting with the method_missing calls that build the stack.
8
+ #
9
+ class FauxColumn
10
+ # Create a new column with a pre-defined stack. The top element in the
11
+ # stack will get shifted to be the name value.
12
+ #
13
+ def initialize(*stack)
14
+ @name = stack.pop
15
+ @stack = stack
16
+ end
17
+
18
+ def self.coerce(columns)
19
+ case columns
20
+ when Symbol, String
21
+ FauxColumn.new(columns)
22
+ when Array
23
+ columns.collect { |col| FauxColumn.coerce(col) }
24
+ when FauxColumn
25
+ columns
26
+ else
27
+ nil
28
+ end
29
+ end
30
+
31
+ # Can't use normal method name, as that could be an association or
32
+ # column name.
33
+ #
34
+ def __name
35
+ @name
36
+ end
37
+
38
+ # Can't use normal method name, as that could be an association or
39
+ # column name.
40
+ #
41
+ def __stack
42
+ @stack
43
+ end
44
+
45
+ # Returns true if the stack is empty *and* if the name is a string -
46
+ # which is an indication that of raw SQL, as opposed to a value from a
47
+ # table's column.
48
+ #
49
+ def is_string?
50
+ @name.is_a?(String) && @stack.empty?
51
+ end
52
+
53
+ # This handles any 'invalid' method calls and sets them as the name,
54
+ # and pushing the previous name into the stack. The object returns
55
+ # itself.
56
+ #
57
+ # If there's a single argument, it becomes the name, and the method
58
+ # symbol goes into the stack as well. Multiple arguments means new
59
+ # columns with the original stack and new names (from each argument) gets
60
+ # returned.
61
+ #
62
+ # Easier to explain with examples:
63
+ #
64
+ # col = FauxColumn.new :a, :b, :c
65
+ # col.__name #=> :c
66
+ # col.__stack #=> [:a, :b]
67
+ #
68
+ # col.whatever #=> col
69
+ # col.__name #=> :whatever
70
+ # col.__stack #=> [:a, :b, :c]
71
+ #
72
+ # col.something(:id) #=> col
73
+ # col.__name #=> :id
74
+ # col.__stack #=> [:a, :b, :c, :whatever, :something]
75
+ #
76
+ # cols = col.short(:x, :y, :z)
77
+ # cols[0].__name #=> :x
78
+ # cols[0].__stack #=> [:a, :b, :c, :whatever, :something, :short]
79
+ # cols[1].__name #=> :y
80
+ # cols[1].__stack #=> [:a, :b, :c, :whatever, :something, :short]
81
+ # cols[2].__name #=> :z
82
+ # cols[2].__stack #=> [:a, :b, :c, :whatever, :something, :short]
83
+ #
84
+ # Also, this allows method chaining to build up a relevant stack:
85
+ #
86
+ # col = FauxColumn.new :a, :b
87
+ # col.__name #=> :b
88
+ # col.__stack #=> [:a]
89
+ #
90
+ # col.one.two.three #=> col
91
+ # col.__name #=> :three
92
+ # col.__stack #=> [:a, :b, :one, :two]
93
+ #
94
+ def method_missing(method, *args)
95
+ @stack << @name
96
+ @name = method
97
+
98
+ if (args.empty?)
99
+ self
100
+ elsif (args.length == 1)
101
+ method_missing(args.first)
102
+ else
103
+ args.collect { |arg|
104
+ FauxColumn.new(@stack + [@name, arg])
105
+ }
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,162 @@
1
+ module ThinkingSphinx
2
+ class Property
3
+ attr_accessor :alias, :columns, :associations, :model, :faceted, :admin
4
+
5
+ def initialize(source, columns, options = {})
6
+ @source = source
7
+ @model = source.model
8
+ @columns = Array(columns)
9
+ @associations = {}
10
+
11
+ raise "Cannot define a field or attribute in #{source.model.name} with no columns. Maybe you are trying to index a field with a reserved name (id, name). You can fix this error by using a symbol rather than a bare name (:id instead of id)." if @columns.empty? || @columns.any? { |column| !column.respond_to?(:__stack) }
12
+
13
+ @alias = options[:as]
14
+ @faceted = options[:facet]
15
+ @admin = options[:admin]
16
+
17
+ @alias = @alias.to_sym unless @alias.blank?
18
+
19
+ @columns.each { |col|
20
+ @associations[col] = association_stack(col.__stack.clone).each { |assoc|
21
+ assoc.join_to(source.base)
22
+ }
23
+ }
24
+ end
25
+
26
+ # Returns the unique name of the attribute - which is either the alias of
27
+ # the attribute, or the name of the only column - if there is only one. If
28
+ # there isn't, there should be an alias. Else things probably won't work.
29
+ # Consider yourself warned.
30
+ #
31
+ def unique_name
32
+ if @columns.length == 1
33
+ @alias || @columns.first.__name
34
+ else
35
+ @alias
36
+ end
37
+ end
38
+
39
+ def to_facet
40
+ return nil unless @faceted
41
+
42
+ ThinkingSphinx::Facet.new(self)
43
+ end
44
+
45
+ # Get the part of the GROUP BY clause related to this attribute - if one is
46
+ # needed. If not, all you'll get back is nil. The latter will happen if
47
+ # there isn't actually a real column to get data from, or if there's
48
+ # multiple data values (read: a has_many or has_and_belongs_to_many
49
+ # association).
50
+ #
51
+ def to_group_sql
52
+ case
53
+ when is_many?, is_string?, ThinkingSphinx.use_group_by_shortcut?
54
+ nil
55
+ else
56
+ @columns.collect { |column|
57
+ column_with_prefix(column)
58
+ }
59
+ end
60
+ end
61
+
62
+ def changed?(instance)
63
+ return true if is_string? || @columns.any? { |col| !col.__stack.empty? }
64
+
65
+ !@columns.all? { |col|
66
+ instance.respond_to?("#{col.__name.to_s}_changed?") &&
67
+ !instance.send("#{col.__name.to_s}_changed?")
68
+ }
69
+ end
70
+
71
+ def admin?
72
+ admin
73
+ end
74
+
75
+ def public?
76
+ !admin
77
+ end
78
+
79
+ private
80
+
81
+ # Could there be more than one value related to the parent record? If so,
82
+ # then this will return true. If not, false. It's that simple.
83
+ #
84
+ def is_many?
85
+ associations.values.flatten.any? { |assoc| assoc.is_many? }
86
+ end
87
+
88
+ # Returns true if any of the columns are string values, instead of database
89
+ # column references.
90
+ def is_string?
91
+ columns.all? { |col| col.is_string? }
92
+ end
93
+
94
+ def adapter
95
+ @adapter ||= @model.sphinx_database_adapter
96
+ end
97
+
98
+ def quote_with_table(table, column)
99
+ "#{quote_table_name(table)}.#{quote_column(column)}"
100
+ end
101
+
102
+ def quote_column(column)
103
+ @model.connection.quote_column_name(column)
104
+ end
105
+
106
+ def quote_table_name(table_name)
107
+ @model.connection.quote_table_name(table_name)
108
+ end
109
+
110
+ # Indication of whether the columns should be concatenated with a space
111
+ # between each value. True if there's either multiple sources or multiple
112
+ # associations.
113
+ #
114
+ def concat_ws?
115
+ multiple_associations? || @columns.length > 1
116
+ end
117
+
118
+ # Checks whether any column requires multiple associations (which only
119
+ # happens for polymorphic situations).
120
+ #
121
+ def multiple_associations?
122
+ associations.any? { |col,assocs| assocs.length > 1 }
123
+ end
124
+
125
+ # Builds a column reference tied to the appropriate associations. This
126
+ # dives into the associations hash and their corresponding joins to
127
+ # figure out how to correctly reference a column in SQL.
128
+ #
129
+ def column_with_prefix(column)
130
+ if column.is_string?
131
+ column.__name
132
+ elsif associations[column].empty?
133
+ "#{@model.quoted_table_name}.#{quote_column(column.__name)}"
134
+ else
135
+ associations[column].collect { |assoc|
136
+ assoc.has_column?(column.__name) ?
137
+ "#{quote_with_table(assoc.join.aliased_table_name, column.__name)}" :
138
+ nil
139
+ }.compact.join(', ')
140
+ end
141
+ end
142
+
143
+ # Gets a stack of associations for a specific path.
144
+ #
145
+ def association_stack(path, parent = nil)
146
+ assocs = []
147
+
148
+ if parent.nil?
149
+ assocs = @source.association(path.shift)
150
+ else
151
+ assocs = parent.children(path.shift)
152
+ end
153
+
154
+ until path.empty?
155
+ point = path.shift
156
+ assocs = assocs.collect { |assoc| assoc.children(point) }.flatten
157
+ end
158
+
159
+ assocs
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,150 @@
1
+ module ThinkingSphinx
2
+ module HashExcept
3
+ # Returns a new hash without the given keys.
4
+ def except(*keys)
5
+ rejected = Set.new(respond_to?(:convert_key) ? keys.map { |key| convert_key(key) } : keys)
6
+ reject { |key,| rejected.include?(key) }
7
+ end
8
+
9
+ # Replaces the hash without only the given keys.
10
+ def except!(*keys)
11
+ replace(except(*keys))
12
+ end
13
+ end
14
+ end
15
+
16
+ Hash.send(
17
+ :include, ThinkingSphinx::HashExcept
18
+ ) unless Hash.instance_methods.include?("except")
19
+
20
+ module ThinkingSphinx
21
+ module ArrayExtractOptions
22
+ def extract_options!
23
+ last.is_a?(::Hash) ? pop : {}
24
+ end
25
+ end
26
+ end
27
+
28
+ Array.send(
29
+ :include, ThinkingSphinx::ArrayExtractOptions
30
+ ) unless Array.instance_methods.include?("extract_options!")
31
+
32
+ module ThinkingSphinx
33
+ module AbstractQuotedTableName
34
+ def quote_table_name(name)
35
+ quote_column_name(name)
36
+ end
37
+ end
38
+ end
39
+
40
+ ActiveRecord::ConnectionAdapters::AbstractAdapter.send(
41
+ :include, ThinkingSphinx::AbstractQuotedTableName
42
+ ) unless ActiveRecord::ConnectionAdapters::AbstractAdapter.instance_methods.include?("quote_table_name")
43
+
44
+ module ThinkingSphinx
45
+ module MysqlQuotedTableName
46
+ def quote_table_name(name) #:nodoc:
47
+ quote_column_name(name).gsub('.', '`.`')
48
+ end
49
+ end
50
+ end
51
+
52
+ if ActiveRecord::ConnectionAdapters.constants.include?("MysqlAdapter") or ActiveRecord::Base.respond_to?(:jdbcmysql_connection)
53
+ adapter = ActiveRecord::ConnectionAdapters.const_get(
54
+ defined?(JRUBY_VERSION) ? :JdbcAdapter : :MysqlAdapter
55
+ )
56
+ unless adapter.instance_methods.include?("quote_table_name")
57
+ adapter.send(:include, ThinkingSphinx::MysqlQuotedTableName)
58
+ end
59
+ end
60
+
61
+ module ThinkingSphinx
62
+ module ActiveRecordQuotedName
63
+ def quoted_table_name
64
+ self.connection.quote_table_name(self.table_name)
65
+ end
66
+ end
67
+ end
68
+
69
+ ActiveRecord::Base.extend(
70
+ ThinkingSphinx::ActiveRecordQuotedName
71
+ ) unless ActiveRecord::Base.respond_to?("quoted_table_name")
72
+
73
+ module ThinkingSphinx
74
+ module ActiveRecordStoreFullSTIClass
75
+ def store_full_sti_class
76
+ false
77
+ end
78
+ end
79
+ end
80
+
81
+ ActiveRecord::Base.extend(
82
+ ThinkingSphinx::ActiveRecordStoreFullSTIClass
83
+ ) unless ActiveRecord::Base.respond_to?(:store_full_sti_class)
84
+
85
+ module ThinkingSphinx
86
+ module ClassAttributeMethods
87
+ def cattr_reader(*syms)
88
+ syms.flatten.each do |sym|
89
+ next if sym.is_a?(Hash)
90
+ class_eval(<<-EOS, __FILE__, __LINE__)
91
+ unless defined? @@#{sym}
92
+ @@#{sym} = nil
93
+ end
94
+
95
+ def self.#{sym}
96
+ @@#{sym}
97
+ end
98
+
99
+ def #{sym}
100
+ @@#{sym}
101
+ end
102
+ EOS
103
+ end
104
+ end
105
+
106
+ def cattr_writer(*syms)
107
+ options = syms.extract_options!
108
+ syms.flatten.each do |sym|
109
+ class_eval(<<-EOS, __FILE__, __LINE__)
110
+ unless defined? @@#{sym}
111
+ @@#{sym} = nil
112
+ end
113
+
114
+ def self.#{sym}=(obj)
115
+ @@#{sym} = obj
116
+ end
117
+
118
+ #{"
119
+ def #{sym}=(obj)
120
+ @@#{sym} = obj
121
+ end
122
+ " unless options[:instance_writer] == false }
123
+ EOS
124
+ end
125
+ end
126
+
127
+ def cattr_accessor(*syms)
128
+ cattr_reader(*syms)
129
+ cattr_writer(*syms)
130
+ end
131
+ end
132
+ end
133
+
134
+ Class.extend(
135
+ ThinkingSphinx::ClassAttributeMethods
136
+ ) unless Class.respond_to?(:cattr_reader)
137
+
138
+ module ThinkingSphinx
139
+ module MetaClass
140
+ def metaclass
141
+ class << self
142
+ self
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ unless Object.new.respond_to?(:metaclass)
149
+ Object.send(:include, ThinkingSphinx::MetaClass)
150
+ end
@@ -0,0 +1,707 @@
1
+ # encoding: UTF-8
2
+ module ThinkingSphinx
3
+ # Once you've got those indexes in and built, this is the stuff that
4
+ # matters - how to search! This class provides a generic search
5
+ # interface - which you can use to search all your indexed models at once.
6
+ # Most times, you will just want a specific model's results - to search and
7
+ # search_for_ids methods will do the job in exactly the same manner when
8
+ # called from a model.
9
+ #
10
+ class Search
11
+ CoreMethods = %w( == class class_eval extend frozen? id instance_eval
12
+ instance_of? instance_values instance_variable_defined?
13
+ instance_variable_get instance_variable_set instance_variables is_a?
14
+ kind_of? member? method methods nil? object_id respond_to? send should
15
+ type )
16
+ SafeMethods = %w( partition private_methods protected_methods
17
+ public_methods send )
18
+
19
+ instance_methods.select { |method|
20
+ method.to_s[/^__/].nil? && !CoreMethods.include?(method.to_s)
21
+ }.each { |method|
22
+ undef_method method
23
+ }
24
+
25
+ HashOptions = [:conditions, :with, :without, :with_all]
26
+ ArrayOptions = [:classes, :without_ids]
27
+
28
+ attr_reader :args, :options
29
+
30
+ # Deprecated. Use ThinkingSphinx.search
31
+ def self.search(*args)
32
+ log 'ThinkingSphinx::Search.search is deprecated. Please use ThinkingSphinx.search instead.'
33
+ ThinkingSphinx.search *args
34
+ end
35
+
36
+ # Deprecated. Use ThinkingSphinx.search_for_ids
37
+ def self.search_for_ids(*args)
38
+ log 'ThinkingSphinx::Search.search_for_ids is deprecated. Please use ThinkingSphinx.search_for_ids instead.'
39
+ ThinkingSphinx.search_for_ids *args
40
+ end
41
+
42
+ # Deprecated. Use ThinkingSphinx.search_for_ids
43
+ def self.search_for_id(*args)
44
+ log 'ThinkingSphinx::Search.search_for_id is deprecated. Please use ThinkingSphinx.search_for_id instead.'
45
+ ThinkingSphinx.search_for_id *args
46
+ end
47
+
48
+ # Deprecated. Use ThinkingSphinx.count
49
+ def self.count(*args)
50
+ log 'ThinkingSphinx::Search.count is deprecated. Please use ThinkingSphinx.count instead.'
51
+ ThinkingSphinx.count *args
52
+ end
53
+
54
+ # Deprecated. Use ThinkingSphinx.facets
55
+ def self.facets(*args)
56
+ log 'ThinkingSphinx::Search.facets is deprecated. Please use ThinkingSphinx.facets instead.'
57
+ ThinkingSphinx.facets *args
58
+ end
59
+
60
+ def initialize(*args)
61
+ @array = []
62
+ @options = args.extract_options!
63
+ @args = args
64
+ end
65
+
66
+ def to_a
67
+ populate
68
+ @array
69
+ end
70
+
71
+ # Indication of whether the request has been made to Sphinx for the search
72
+ # query.
73
+ #
74
+ # @return [Boolean] true if the results have been requested.
75
+ #
76
+ def populated?
77
+ !!@populated
78
+ end
79
+
80
+ # The query result hash from Riddle.
81
+ #
82
+ # @return [Hash] Raw Sphinx results
83
+ #
84
+ def results
85
+ populate
86
+ @results
87
+ end
88
+
89
+ def method_missing(method, *args, &block)
90
+ if is_scope?(method)
91
+ add_scope(method, *args, &block)
92
+ return self
93
+ elsif method.to_s[/^each_with_.*/].nil? && !@array.respond_to?(method)
94
+ super
95
+ elsif !SafeMethods.include?(method.to_s)
96
+ populate
97
+ end
98
+
99
+ if method.to_s[/^each_with_.*/] && !@array.respond_to?(method)
100
+ each_with_attribute method.to_s.gsub(/^each_with_/, ''), &block
101
+ else
102
+ @array.send(method, *args, &block)
103
+ end
104
+ end
105
+
106
+ # Returns true if the Search object or the underlying Array object respond
107
+ # to the requested method.
108
+ #
109
+ # @param [Symbol] method The method name
110
+ # @return [Boolean] true if either Search or Array responds to the method.
111
+ #
112
+ def respond_to?(method)
113
+ super || @array.respond_to?(method)
114
+ end
115
+
116
+ # The current page number of the result set. Defaults to 1 if no page was
117
+ # explicitly requested.
118
+ #
119
+ # @return [Integer]
120
+ #
121
+ def current_page
122
+ @options[:page].blank? ? 1 : @options[:page].to_i
123
+ end
124
+
125
+ # The next page number of the result set. If there are no more pages
126
+ # available, nil is returned.
127
+ #
128
+ # @return [Integer, nil]
129
+ #
130
+ def next_page
131
+ current_page >= total_pages ? nil : current_page + 1
132
+ end
133
+
134
+ # The previous page number of the result set. If this is the first page,
135
+ # then nil is returned.
136
+ #
137
+ # @return [Integer, nil]
138
+ #
139
+ def previous_page
140
+ current_page == 1 ? nil : current_page - 1
141
+ end
142
+
143
+ # The amount of records per set of paged results. Defaults to 20 unless a
144
+ # specific page size is requested.
145
+ #
146
+ # @return [Integer]
147
+ #
148
+ def per_page
149
+ @options[:limit] || @options[:per_page] || 20
150
+ end
151
+
152
+ # The total number of pages available if the results are paginated.
153
+ #
154
+ # @return [Integer]
155
+ #
156
+ def total_pages
157
+ populate
158
+ @total_pages ||= (@results[:total] / per_page.to_f).ceil
159
+ end
160
+ # Compatibility with older versions of will_paginate
161
+ alias_method :page_count, :total_pages
162
+
163
+ # The total number of search results available.
164
+ #
165
+ # @return [Integer]
166
+ #
167
+ def total_entries
168
+ populate
169
+ @total_entries ||= @results[:total_found]
170
+ end
171
+
172
+ # The current page's offset, based on the number of records per page.
173
+ #
174
+ # @return [Integer]
175
+ #
176
+ def offset
177
+ (current_page - 1) * per_page
178
+ end
179
+
180
+ def indexes
181
+ return options[:index] if options[:index]
182
+ return '*' if classes.empty?
183
+
184
+ classes.collect { |klass| klass.sphinx_index_names }.flatten.join(',')
185
+ end
186
+
187
+ def each_with_groupby_and_count(&block)
188
+ populate
189
+ results[:matches].each_with_index do |match, index|
190
+ yield self[index],
191
+ match[:attributes]["@groupby"],
192
+ match[:attributes]["@count"]
193
+ end
194
+ end
195
+
196
+ def each_with_weighting(&block)
197
+ populate
198
+ results[:matches].each_with_index do |match, index|
199
+ yield self[index], match[:weight]
200
+ end
201
+ end
202
+
203
+ def excerpt_for(string, model = nil)
204
+ if model.nil? && one_class
205
+ model ||= one_class
206
+ end
207
+
208
+ populate
209
+ client.excerpts(
210
+ :docs => [string],
211
+ :words => results[:words].keys.join(' '),
212
+ :index => "#{model.source_of_sphinx_index.sphinx_name}_core"
213
+ ).first
214
+ end
215
+
216
+ def search(*args)
217
+ merge_search ThinkingSphinx::Search.new(*args)
218
+ self
219
+ end
220
+
221
+ private
222
+
223
+ def config
224
+ ThinkingSphinx::Configuration.instance
225
+ end
226
+
227
+ def populate
228
+ return if @populated
229
+ @populated = true
230
+
231
+ retry_on_stale_index do
232
+ begin
233
+ log "Querying Sphinx: #{query}"
234
+ @results = client.query query, indexes, comment
235
+ rescue Errno::ECONNREFUSED => err
236
+ raise ThinkingSphinx::ConnectionError,
237
+ 'Connection to Sphinx Daemon (searchd) failed.'
238
+ end
239
+
240
+ if options[:ids_only]
241
+ replace @results[:matches].collect { |match|
242
+ match[:attributes]["sphinx_internal_id"]
243
+ }
244
+ else
245
+ replace instances_from_matches
246
+ add_excerpter
247
+ add_sphinx_attributes
248
+ end
249
+ end
250
+ end
251
+
252
+ def add_excerpter
253
+ each do |object|
254
+ next if object.respond_to?(:excerpts)
255
+
256
+ excerpter = ThinkingSphinx::Excerpter.new self, object
257
+ block = lambda { excerpter }
258
+
259
+ object.metaclass.instance_eval do
260
+ define_method(:excerpts, &block)
261
+ end
262
+ end
263
+ end
264
+
265
+ def add_sphinx_attributes
266
+ each do |object|
267
+ next if object.nil? || object.respond_to?(:sphinx_attributes)
268
+
269
+ match = @results[:matches].detect { |match|
270
+ match[:attributes]['sphinx_internal_id'] == object.
271
+ primary_key_for_sphinx &&
272
+ match[:attributes]['class_crc'] == object.class.to_crc32
273
+ }
274
+ next if match.nil?
275
+
276
+ object.metaclass.instance_eval do
277
+ define_method(:sphinx_attributes) { match[:attributes] }
278
+ end
279
+ end
280
+ end
281
+
282
+ def self.log(message, method = :debug)
283
+ return if ::ActiveRecord::Base.logger.nil?
284
+ ::ActiveRecord::Base.logger.send method, message
285
+ end
286
+
287
+ def log(message, method = :debug)
288
+ self.class.log(message, method)
289
+ end
290
+
291
+ def client
292
+ client = config.client
293
+
294
+ index_options = one_class ?
295
+ one_class.sphinx_indexes.first.local_options : {}
296
+
297
+ [
298
+ :max_matches, :group_by, :group_function, :group_clause,
299
+ :group_distinct, :id_range, :cut_off, :retry_count, :retry_delay,
300
+ :rank_mode, :max_query_time, :field_weights
301
+ ].each do |key|
302
+ # puts "key: #{key}"
303
+ value = options[key] || index_options[key]
304
+ # puts "value: #{value.inspect}"
305
+ client.send("#{key}=", value) if value
306
+ end
307
+
308
+ client.limit = per_page
309
+ client.offset = offset
310
+ client.match_mode = match_mode
311
+ client.filters = filters
312
+ client.sort_mode = sort_mode
313
+ client.sort_by = sort_by
314
+ client.group_by = group_by if group_by
315
+ client.group_function = group_function if group_function
316
+ client.index_weights = index_weights
317
+ client.anchor = anchor
318
+
319
+ client
320
+ end
321
+
322
+ def retry_on_stale_index(&block)
323
+ stale_ids = []
324
+ retries = stale_retries
325
+
326
+ begin
327
+ options[:raise_on_stale] = retries > 0
328
+ block.call
329
+
330
+ # If ThinkingSphinx::Search#instances_from_matches found records in
331
+ # Sphinx but not in the DB and the :raise_on_stale option is set, this
332
+ # exception is raised. We retry a limited number of times, excluding the
333
+ # stale ids from the search.
334
+ rescue StaleIdsException => err
335
+ retries -= 1
336
+
337
+ # For logging
338
+ stale_ids |= err.ids
339
+ # ID exclusion
340
+ options[:without_ids] = Array(options[:without_ids]) | err.ids
341
+
342
+ log 'Sphinx Stale Ids (%s %s left): %s' % [
343
+ retries, (retries == 1 ? 'try' : 'tries'), stale_ids.join(', ')
344
+ ]
345
+ retry
346
+ end
347
+ end
348
+
349
+ def classes
350
+ @classes ||= options[:classes] || []
351
+ end
352
+
353
+ def one_class
354
+ @one_class ||= classes.length != 1 ? nil : classes.first
355
+ end
356
+
357
+ def query
358
+ @query ||= begin
359
+ q = @args.join(' ') << conditions_as_query
360
+ (options[:star] ? star_query(q) : q).strip
361
+ end
362
+ end
363
+
364
+ def conditions_as_query
365
+ return '' if @options[:conditions].blank?
366
+
367
+ # Soon to be deprecated.
368
+ keys = @options[:conditions].keys.reject { |key|
369
+ attributes.include?(key.to_sym)
370
+ }
371
+
372
+ ' ' + keys.collect { |key|
373
+ "@#{key} #{options[:conditions][key]}"
374
+ }.join(' ')
375
+ end
376
+
377
+ def star_query(query)
378
+ token = options[:star].is_a?(Regexp) ? options[:star] : /\w+/u
379
+
380
+ query.gsub(/("#{token}(.*?#{token})?"|(?![!-])#{token})/u) do
381
+ pre, proper, post = $`, $&, $'
382
+ # E.g. "@foo", "/2", "~3", but not as part of a token
383
+ is_operator = pre.match(%r{(\W|^)[@~/]\Z})
384
+ # E.g. "foo bar", with quotes
385
+ is_quote = proper.starts_with?('"') && proper.ends_with?('"')
386
+ has_star = pre.ends_with?("*") || post.starts_with?("*")
387
+ if is_operator || is_quote || has_star
388
+ proper
389
+ else
390
+ "*#{proper}*"
391
+ end
392
+ end
393
+ end
394
+
395
+ def comment
396
+ options[:comment] || ''
397
+ end
398
+
399
+ def match_mode
400
+ options[:match_mode] || (options[:conditions].blank? ? :all : :extended)
401
+ end
402
+
403
+ def sort_mode
404
+ @sort_mode ||= case options[:sort_mode]
405
+ when :asc
406
+ :attr_asc
407
+ when :desc
408
+ :attr_desc
409
+ when nil
410
+ case options[:order]
411
+ when String
412
+ :extended
413
+ when Symbol
414
+ :attr_asc
415
+ else
416
+ :relevance
417
+ end
418
+ else
419
+ options[:sort_mode]
420
+ end
421
+ end
422
+
423
+ def sort_by
424
+ case @sort_by = (options[:sort_by] || options[:order])
425
+ when String
426
+ sorted_fields_to_attributes(@sort_by)
427
+ when Symbol
428
+ field_names.include?(@sort_by) ?
429
+ @sort_by.to_s.concat('_sort') : @sort_by.to_s
430
+ else
431
+ ''
432
+ end
433
+ end
434
+
435
+ def field_names
436
+ return [] unless one_class
437
+
438
+ one_class.sphinx_indexes.collect { |index|
439
+ index.fields.collect { |field| field.unique_name }
440
+ }.flatten
441
+ end
442
+
443
+ def sorted_fields_to_attributes(order_string)
444
+ field_names.each { |field|
445
+ order_string.gsub!(/(^|\s)#{field}(,?\s|$)/) { |match|
446
+ match.gsub field.to_s, field.to_s.concat("_sort")
447
+ }
448
+ }
449
+
450
+ order_string
451
+ end
452
+
453
+ # Turn :index_weights => { "foo" => 2, User => 1 } into :index_weights =>
454
+ # { "foo" => 2, "user_core" => 1, "user_delta" => 1 }
455
+ #
456
+ def index_weights
457
+ weights = options[:index_weights] || {}
458
+ weights.keys.inject({}) do |hash, key|
459
+ if key.is_a?(Class)
460
+ name = ThinkingSphinx::Index.name_for(key)
461
+ hash["#{name}_core"] = weights[key]
462
+ hash["#{name}_delta"] = weights[key]
463
+ else
464
+ hash[key] = weights[key]
465
+ end
466
+
467
+ hash
468
+ end
469
+ end
470
+
471
+ def group_by
472
+ options[:group] ? options[:group].to_s : nil
473
+ end
474
+
475
+ def group_function
476
+ options[:group] ? :attr : nil
477
+ end
478
+
479
+ def internal_filters
480
+ filters = [Riddle::Client::Filter.new('sphinx_deleted', [0])]
481
+
482
+ class_crcs = classes.collect { |klass|
483
+ klass.to_crc32s
484
+ }.flatten
485
+
486
+ unless class_crcs.empty?
487
+ filters << Riddle::Client::Filter.new('class_crc', class_crcs)
488
+ end
489
+
490
+ filters << Riddle::Client::Filter.new(
491
+ 'sphinx_internal_id', filter_value(options[:without_ids]), true
492
+ ) if options[:without_ids]
493
+
494
+ filters
495
+ end
496
+
497
+ def condition_filters
498
+ (options[:conditions] || {}).collect { |attrib, value|
499
+ if attributes.include?(attrib.to_sym)
500
+ puts <<-MSG
501
+ Deprecation Warning: filters on attributes should be done using the :with
502
+ option, not :conditions. For example:
503
+ :with => {:#{attrib} => #{value.inspect}}
504
+ MSG
505
+ Riddle::Client::Filter.new attrib.to_s, filter_value(value)
506
+ else
507
+ nil
508
+ end
509
+ }.compact
510
+ end
511
+
512
+ def filters
513
+ internal_filters +
514
+ condition_filters +
515
+ (options[:with] || {}).collect { |attrib, value|
516
+ Riddle::Client::Filter.new attrib.to_s, filter_value(value)
517
+ } +
518
+ (options[:without] || {}).collect { |attrib, value|
519
+ Riddle::Client::Filter.new attrib.to_s, filter_value(value), true
520
+ } +
521
+ (options[:with_all] || {}).collect { |attrib, values|
522
+ Array(values).collect { |value|
523
+ Riddle::Client::Filter.new attrib.to_s, filter_value(value)
524
+ }
525
+ }.flatten
526
+ end
527
+
528
+ # When passed a Time instance, returns the integer timestamp.
529
+ #
530
+ # If using Rails 2.1+, need to handle timezones to translate them back to
531
+ # UTC, as that's what datetimes will be stored as by MySQL.
532
+ #
533
+ # in_time_zone is a method that was added for the timezone support in
534
+ # Rails 2.1, which is why it's used for testing. I'm sure there's better
535
+ # ways, but this does the job.
536
+ #
537
+ def filter_value(value)
538
+ case value
539
+ when Range
540
+ filter_value(value.first).first..filter_value(value.last).first
541
+ when Array
542
+ value.collect { |v| filter_value(v) }.flatten
543
+ when Time
544
+ value.respond_to?(:in_time_zone) ? [value.utc.to_i] : [value.to_i]
545
+ when NilClass
546
+ 0
547
+ else
548
+ Array(value)
549
+ end
550
+ end
551
+
552
+ def anchor
553
+ return {} unless options[:geo] || (options[:lat] && options[:lng])
554
+
555
+ {
556
+ :latitude => options[:geo] ? options[:geo].first : options[:lat],
557
+ :longitude => options[:geo] ? options[:geo].last : options[:lng],
558
+ :latitude_attribute => latitude_attr.to_s,
559
+ :longitude_attribute => longitude_attr.to_s
560
+ }
561
+ end
562
+
563
+ def latitude_attr
564
+ options[:latitude_attr] ||
565
+ index_option(:latitude_attr) ||
566
+ attribute(:lat, :latitude)
567
+ end
568
+
569
+ def longitude_attr
570
+ options[:longitude_attr] ||
571
+ index_option(:longitude_attr) ||
572
+ attribute(:lon, :lng, :longitude)
573
+ end
574
+
575
+ def index_option(key)
576
+ return nil unless one_class
577
+
578
+ one_class.sphinx_indexes.collect { |index|
579
+ index.local_options[key]
580
+ }.compact.first
581
+ end
582
+
583
+ def attribute(*keys)
584
+ return nil unless one_class
585
+
586
+ keys.detect { |key|
587
+ attributes.include?(key)
588
+ }
589
+ end
590
+
591
+ def attributes
592
+ return [] unless one_class
593
+
594
+ attributes = one_class.sphinx_indexes.collect { |index|
595
+ index.attributes.collect { |attrib| attrib.unique_name }
596
+ }.flatten
597
+ end
598
+
599
+ def stale_retries
600
+ case options[:retry_stale]
601
+ when TrueClass
602
+ 3
603
+ when nil, FalseClass
604
+ 0
605
+ else
606
+ options[:retry_stale].to_i
607
+ end
608
+ end
609
+
610
+ def instances_from_class(klass, matches)
611
+ index_options = klass.sphinx_index_options
612
+
613
+ ids = matches.collect { |match| match[:attributes]["sphinx_internal_id"] }
614
+ instances = ids.length > 0 ? klass.find(
615
+ :all,
616
+ :joins => options[:joins],
617
+ :conditions => {klass.primary_key_for_sphinx.to_sym => ids},
618
+ :include => (options[:include] || index_options[:include]),
619
+ :select => (options[:select] || index_options[:select]),
620
+ :order => (options[:sql_order] || index_options[:sql_order])
621
+ ) : []
622
+
623
+ # Raise an exception if we find records in Sphinx but not in the DB, so
624
+ # the search method can retry without them. See
625
+ # ThinkingSphinx::Search.retry_search_on_stale_index.
626
+ if options[:raise_on_stale] && instances.length < ids.length
627
+ stale_ids = ids - instances.map { |i| i.id }
628
+ raise StaleIdsException, stale_ids
629
+ end
630
+
631
+ # if the user has specified an SQL order, return the collection
632
+ # without rearranging it into the Sphinx order
633
+ return instances if (options[:sql_order] || index_options[:sql_order])
634
+
635
+ ids.collect { |obj_id|
636
+ instances.detect do |obj|
637
+ obj.primary_key_for_sphinx == obj_id
638
+ end
639
+ }
640
+ end
641
+
642
+ # Group results by class and call #find(:all) once for each group to reduce
643
+ # the number of #find's in multi-model searches.
644
+ #
645
+ def instances_from_matches
646
+ return single_class_results if one_class
647
+
648
+ groups = results[:matches].group_by { |match|
649
+ match[:attributes]["class_crc"]
650
+ }
651
+ groups.each do |crc, group|
652
+ group.replace(
653
+ instances_from_class(class_from_crc(crc), group)
654
+ )
655
+ end
656
+
657
+ results[:matches].collect do |match|
658
+ groups.detect { |crc, group|
659
+ crc == match[:attributes]["class_crc"]
660
+ }[1].compact.detect { |obj|
661
+ obj.primary_key_for_sphinx == match[:attributes]["sphinx_internal_id"]
662
+ }
663
+ end
664
+ end
665
+
666
+ def single_class_results
667
+ instances_from_class one_class, results[:matches]
668
+ end
669
+
670
+ def class_from_crc(crc)
671
+ config.models_by_crc[crc].constantize
672
+ end
673
+
674
+ def each_with_attribute(attribute, &block)
675
+ populate
676
+ results[:matches].each_with_index do |match, index|
677
+ yield self[index],
678
+ (match[:attributes][attribute] || match[:attributes]["@#{attribute}"])
679
+ end
680
+ end
681
+
682
+ def is_scope?(method)
683
+ one_class && one_class.sphinx_scopes.include?(method)
684
+ end
685
+
686
+ def add_scope(method, *args, &block)
687
+ merge_search one_class.send(method, *args, &block)
688
+ end
689
+
690
+ def merge_search(search)
691
+ search.args.each { |arg| args << arg }
692
+
693
+ search.options.keys.each do |key|
694
+ if HashOptions.include?(key)
695
+ options[key] ||= {}
696
+ options[key].merge! search.options[key]
697
+ elsif ArrayOptions.include?(key)
698
+ options[key] ||= []
699
+ options[key] += search.options[key]
700
+ options[key].uniq!
701
+ else
702
+ options[key] = search.options[key]
703
+ end
704
+ end
705
+ end
706
+ end
707
+ end