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