sayso-thinking-sphinx 2.0.3.001

Sign up to get free protection for your applications and to get access to all the features.
Files changed (164) hide show
  1. data/LICENCE +20 -0
  2. data/README.textile +251 -0
  3. data/VERSION +1 -0
  4. data/features/abstract_inheritance.feature +10 -0
  5. data/features/alternate_primary_key.feature +27 -0
  6. data/features/attribute_transformation.feature +22 -0
  7. data/features/attribute_updates.feature +77 -0
  8. data/features/deleting_instances.feature +67 -0
  9. data/features/direct_attributes.feature +11 -0
  10. data/features/excerpts.feature +21 -0
  11. data/features/extensible_delta_indexing.feature +9 -0
  12. data/features/facets.feature +88 -0
  13. data/features/facets_across_model.feature +29 -0
  14. data/features/field_sorting.feature +18 -0
  15. data/features/handling_edits.feature +94 -0
  16. data/features/retry_stale_indexes.feature +24 -0
  17. data/features/searching_across_models.feature +20 -0
  18. data/features/searching_by_index.feature +40 -0
  19. data/features/searching_by_model.feature +168 -0
  20. data/features/searching_with_find_arguments.feature +56 -0
  21. data/features/sphinx_detection.feature +25 -0
  22. data/features/sphinx_scopes.feature +68 -0
  23. data/features/step_definitions/alpha_steps.rb +16 -0
  24. data/features/step_definitions/beta_steps.rb +7 -0
  25. data/features/step_definitions/common_steps.rb +197 -0
  26. data/features/step_definitions/extensible_delta_indexing_steps.rb +7 -0
  27. data/features/step_definitions/facet_steps.rb +96 -0
  28. data/features/step_definitions/find_arguments_steps.rb +36 -0
  29. data/features/step_definitions/gamma_steps.rb +15 -0
  30. data/features/step_definitions/scope_steps.rb +19 -0
  31. data/features/step_definitions/search_steps.rb +94 -0
  32. data/features/step_definitions/sphinx_steps.rb +35 -0
  33. data/features/sti_searching.feature +19 -0
  34. data/features/support/env.rb +27 -0
  35. data/features/support/lib/generic_delta_handler.rb +8 -0
  36. data/features/thinking_sphinx/database.example.yml +3 -0
  37. data/features/thinking_sphinx/db/fixtures/alphas.rb +10 -0
  38. data/features/thinking_sphinx/db/fixtures/authors.rb +1 -0
  39. data/features/thinking_sphinx/db/fixtures/betas.rb +11 -0
  40. data/features/thinking_sphinx/db/fixtures/boxes.rb +9 -0
  41. data/features/thinking_sphinx/db/fixtures/categories.rb +1 -0
  42. data/features/thinking_sphinx/db/fixtures/cats.rb +3 -0
  43. data/features/thinking_sphinx/db/fixtures/comments.rb +24 -0
  44. data/features/thinking_sphinx/db/fixtures/developers.rb +31 -0
  45. data/features/thinking_sphinx/db/fixtures/dogs.rb +3 -0
  46. data/features/thinking_sphinx/db/fixtures/extensible_betas.rb +10 -0
  47. data/features/thinking_sphinx/db/fixtures/foxes.rb +3 -0
  48. data/features/thinking_sphinx/db/fixtures/gammas.rb +10 -0
  49. data/features/thinking_sphinx/db/fixtures/music.rb +4 -0
  50. data/features/thinking_sphinx/db/fixtures/people.rb +1001 -0
  51. data/features/thinking_sphinx/db/fixtures/posts.rb +6 -0
  52. data/features/thinking_sphinx/db/fixtures/robots.rb +14 -0
  53. data/features/thinking_sphinx/db/fixtures/tags.rb +27 -0
  54. data/features/thinking_sphinx/db/migrations/create_alphas.rb +8 -0
  55. data/features/thinking_sphinx/db/migrations/create_animals.rb +5 -0
  56. data/features/thinking_sphinx/db/migrations/create_authors.rb +3 -0
  57. data/features/thinking_sphinx/db/migrations/create_authors_posts.rb +6 -0
  58. data/features/thinking_sphinx/db/migrations/create_betas.rb +5 -0
  59. data/features/thinking_sphinx/db/migrations/create_boxes.rb +5 -0
  60. data/features/thinking_sphinx/db/migrations/create_categories.rb +3 -0
  61. data/features/thinking_sphinx/db/migrations/create_comments.rb +10 -0
  62. data/features/thinking_sphinx/db/migrations/create_developers.rb +7 -0
  63. data/features/thinking_sphinx/db/migrations/create_extensible_betas.rb +5 -0
  64. data/features/thinking_sphinx/db/migrations/create_gammas.rb +3 -0
  65. data/features/thinking_sphinx/db/migrations/create_genres.rb +3 -0
  66. data/features/thinking_sphinx/db/migrations/create_music.rb +6 -0
  67. data/features/thinking_sphinx/db/migrations/create_people.rb +13 -0
  68. data/features/thinking_sphinx/db/migrations/create_posts.rb +5 -0
  69. data/features/thinking_sphinx/db/migrations/create_robots.rb +4 -0
  70. data/features/thinking_sphinx/db/migrations/create_taggings.rb +5 -0
  71. data/features/thinking_sphinx/db/migrations/create_tags.rb +4 -0
  72. data/features/thinking_sphinx/models/alpha.rb +23 -0
  73. data/features/thinking_sphinx/models/andrew.rb +17 -0
  74. data/features/thinking_sphinx/models/animal.rb +5 -0
  75. data/features/thinking_sphinx/models/author.rb +3 -0
  76. data/features/thinking_sphinx/models/beta.rb +13 -0
  77. data/features/thinking_sphinx/models/box.rb +8 -0
  78. data/features/thinking_sphinx/models/cat.rb +3 -0
  79. data/features/thinking_sphinx/models/category.rb +4 -0
  80. data/features/thinking_sphinx/models/comment.rb +10 -0
  81. data/features/thinking_sphinx/models/developer.rb +20 -0
  82. data/features/thinking_sphinx/models/dog.rb +3 -0
  83. data/features/thinking_sphinx/models/extensible_beta.rb +9 -0
  84. data/features/thinking_sphinx/models/fox.rb +5 -0
  85. data/features/thinking_sphinx/models/gamma.rb +5 -0
  86. data/features/thinking_sphinx/models/genre.rb +3 -0
  87. data/features/thinking_sphinx/models/medium.rb +5 -0
  88. data/features/thinking_sphinx/models/music.rb +8 -0
  89. data/features/thinking_sphinx/models/person.rb +24 -0
  90. data/features/thinking_sphinx/models/post.rb +21 -0
  91. data/features/thinking_sphinx/models/robot.rb +12 -0
  92. data/features/thinking_sphinx/models/tag.rb +3 -0
  93. data/features/thinking_sphinx/models/tagging.rb +4 -0
  94. data/lib/cucumber/thinking_sphinx/external_world.rb +12 -0
  95. data/lib/cucumber/thinking_sphinx/internal_world.rb +127 -0
  96. data/lib/cucumber/thinking_sphinx/sql_logger.rb +20 -0
  97. data/lib/thinking-sphinx.rb +1 -0
  98. data/lib/thinking_sphinx.rb +301 -0
  99. data/lib/thinking_sphinx/action_controller.rb +31 -0
  100. data/lib/thinking_sphinx/active_record.rb +352 -0
  101. data/lib/thinking_sphinx/active_record/attribute_updates.rb +52 -0
  102. data/lib/thinking_sphinx/active_record/delta.rb +92 -0
  103. data/lib/thinking_sphinx/active_record/has_many_association.rb +36 -0
  104. data/lib/thinking_sphinx/active_record/has_many_association_with_scopes.rb +21 -0
  105. data/lib/thinking_sphinx/active_record/log_subscriber.rb +61 -0
  106. data/lib/thinking_sphinx/active_record/scopes.rb +93 -0
  107. data/lib/thinking_sphinx/adapters/abstract_adapter.rb +87 -0
  108. data/lib/thinking_sphinx/adapters/mysql_adapter.rb +58 -0
  109. data/lib/thinking_sphinx/adapters/postgresql_adapter.rb +153 -0
  110. data/lib/thinking_sphinx/association.rb +169 -0
  111. data/lib/thinking_sphinx/attribute.rb +389 -0
  112. data/lib/thinking_sphinx/auto_version.rb +38 -0
  113. data/lib/thinking_sphinx/bundled_search.rb +44 -0
  114. data/lib/thinking_sphinx/class_facet.rb +16 -0
  115. data/lib/thinking_sphinx/configuration.rb +355 -0
  116. data/lib/thinking_sphinx/context.rb +76 -0
  117. data/lib/thinking_sphinx/core/string.rb +15 -0
  118. data/lib/thinking_sphinx/deltas.rb +28 -0
  119. data/lib/thinking_sphinx/deltas/default_delta.rb +62 -0
  120. data/lib/thinking_sphinx/deploy/capistrano.rb +101 -0
  121. data/lib/thinking_sphinx/excerpter.rb +23 -0
  122. data/lib/thinking_sphinx/facet.rb +127 -0
  123. data/lib/thinking_sphinx/facet_search.rb +166 -0
  124. data/lib/thinking_sphinx/field.rb +82 -0
  125. data/lib/thinking_sphinx/index.rb +157 -0
  126. data/lib/thinking_sphinx/index/builder.rb +312 -0
  127. data/lib/thinking_sphinx/index/faux_column.rb +118 -0
  128. data/lib/thinking_sphinx/join.rb +37 -0
  129. data/lib/thinking_sphinx/property.rb +185 -0
  130. data/lib/thinking_sphinx/railtie.rb +46 -0
  131. data/lib/thinking_sphinx/search.rb +950 -0
  132. data/lib/thinking_sphinx/search_methods.rb +439 -0
  133. data/lib/thinking_sphinx/source.rb +163 -0
  134. data/lib/thinking_sphinx/source/internal_properties.rb +46 -0
  135. data/lib/thinking_sphinx/source/sql.rb +148 -0
  136. data/lib/thinking_sphinx/tasks.rb +139 -0
  137. data/lib/thinking_sphinx/test.rb +55 -0
  138. data/spec/thinking_sphinx/active_record/delta_spec.rb +128 -0
  139. data/spec/thinking_sphinx/active_record/has_many_association_spec.rb +72 -0
  140. data/spec/thinking_sphinx/active_record/scopes_spec.rb +176 -0
  141. data/spec/thinking_sphinx/active_record_spec.rb +576 -0
  142. data/spec/thinking_sphinx/adapters/abstract_adapter_spec.rb +145 -0
  143. data/spec/thinking_sphinx/association_spec.rb +216 -0
  144. data/spec/thinking_sphinx/attribute_spec.rb +560 -0
  145. data/spec/thinking_sphinx/auto_version_spec.rb +63 -0
  146. data/spec/thinking_sphinx/configuration_spec.rb +288 -0
  147. data/spec/thinking_sphinx/context_spec.rb +128 -0
  148. data/spec/thinking_sphinx/core/array_spec.rb +9 -0
  149. data/spec/thinking_sphinx/core/string_spec.rb +9 -0
  150. data/spec/thinking_sphinx/excerpter_spec.rb +49 -0
  151. data/spec/thinking_sphinx/facet_search_spec.rb +170 -0
  152. data/spec/thinking_sphinx/facet_spec.rb +359 -0
  153. data/spec/thinking_sphinx/field_spec.rb +127 -0
  154. data/spec/thinking_sphinx/index/builder_spec.rb +508 -0
  155. data/spec/thinking_sphinx/index/faux_column_spec.rb +36 -0
  156. data/spec/thinking_sphinx/index_spec.rb +183 -0
  157. data/spec/thinking_sphinx/search_methods_spec.rb +156 -0
  158. data/spec/thinking_sphinx/search_spec.rb +1387 -0
  159. data/spec/thinking_sphinx/source_spec.rb +253 -0
  160. data/spec/thinking_sphinx/test_spec.rb +20 -0
  161. data/spec/thinking_sphinx_spec.rb +203 -0
  162. data/tasks/distribution.rb +33 -0
  163. data/tasks/testing.rb +80 -0
  164. metadata +509 -0
@@ -0,0 +1,118 @@
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
+ def __path
46
+ @stack + [@name]
47
+ end
48
+
49
+ # Returns true if the stack is empty *and* if the name is a string -
50
+ # which is an indication that of raw SQL, as opposed to a value from a
51
+ # table's column.
52
+ #
53
+ def is_string?
54
+ @name.is_a?(String) && @stack.empty?
55
+ end
56
+
57
+ def to_ary
58
+ [self]
59
+ end
60
+
61
+ # This handles any 'invalid' method calls and sets them as the name,
62
+ # and pushing the previous name into the stack. The object returns
63
+ # itself.
64
+ #
65
+ # If there's a single argument, it becomes the name, and the method
66
+ # symbol goes into the stack as well. Multiple arguments means new
67
+ # columns with the original stack and new names (from each argument) gets
68
+ # returned.
69
+ #
70
+ # Easier to explain with examples:
71
+ #
72
+ # col = FauxColumn.new :a, :b, :c
73
+ # col.__name #=> :c
74
+ # col.__stack #=> [:a, :b]
75
+ #
76
+ # col.whatever #=> col
77
+ # col.__name #=> :whatever
78
+ # col.__stack #=> [:a, :b, :c]
79
+ #
80
+ # col.something(:id) #=> col
81
+ # col.__name #=> :id
82
+ # col.__stack #=> [:a, :b, :c, :whatever, :something]
83
+ #
84
+ # cols = col.short(:x, :y, :z)
85
+ # cols[0].__name #=> :x
86
+ # cols[0].__stack #=> [:a, :b, :c, :whatever, :something, :short]
87
+ # cols[1].__name #=> :y
88
+ # cols[1].__stack #=> [:a, :b, :c, :whatever, :something, :short]
89
+ # cols[2].__name #=> :z
90
+ # cols[2].__stack #=> [:a, :b, :c, :whatever, :something, :short]
91
+ #
92
+ # Also, this allows method chaining to build up a relevant stack:
93
+ #
94
+ # col = FauxColumn.new :a, :b
95
+ # col.__name #=> :b
96
+ # col.__stack #=> [:a]
97
+ #
98
+ # col.one.two.three #=> col
99
+ # col.__name #=> :three
100
+ # col.__stack #=> [:a, :b, :one, :two]
101
+ #
102
+ def method_missing(method, *args)
103
+ @stack << @name
104
+ @name = method
105
+
106
+ if (args.empty?)
107
+ self
108
+ elsif (args.length == 1)
109
+ method_missing(args.first)
110
+ else
111
+ args.collect { |arg|
112
+ FauxColumn.new(@stack + [@name, arg])
113
+ }
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,37 @@
1
+ module ThinkingSphinx
2
+ class Join
3
+ attr_accessor :source, :column, :associations
4
+
5
+ def initialize(source, column)
6
+ @source = source
7
+ @column = column
8
+
9
+ @associations = association_stack(column.__path.clone).each { |assoc|
10
+ assoc.join_to(source.base)
11
+ }
12
+
13
+ source.joins << self
14
+ end
15
+
16
+ private
17
+
18
+ # Gets a stack of associations for a specific path.
19
+ #
20
+ def association_stack(path, parent = nil)
21
+ assocs = []
22
+
23
+ if parent.nil?
24
+ assocs = @source.association(path.shift)
25
+ else
26
+ assocs = parent.children(path.shift)
27
+ end
28
+
29
+ until path.empty?
30
+ point = path.shift
31
+ assocs = assocs.collect { |assoc| assoc.children(point) }.flatten
32
+ end
33
+
34
+ assocs
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,185 @@
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
+ @sortable = options[:sortable] || false
17
+ @value_source = options[:value]
18
+
19
+ @alias = @alias.to_sym unless @alias.blank?
20
+
21
+ @columns.each { |col|
22
+ @associations[col] = association_stack(col.__stack.clone).each { |assoc|
23
+ assoc.join_to(source.base)
24
+ }
25
+ }
26
+ end
27
+
28
+ # Returns the unique name of the attribute - which is either the alias of
29
+ # the attribute, or the name of the only column - if there is only one. If
30
+ # there isn't, there should be an alias. Else things probably won't work.
31
+ # Consider yourself warned.
32
+ #
33
+ def unique_name
34
+ if @columns.length == 1
35
+ @alias || @columns.first.__name
36
+ else
37
+ @alias
38
+ end
39
+ end
40
+
41
+ def to_facet
42
+ return nil unless @faceted
43
+
44
+ ThinkingSphinx::Facet.new(self, @value_source)
45
+ end
46
+
47
+ # Get the part of the GROUP BY clause related to this attribute - if one is
48
+ # needed. If not, all you'll get back is nil. The latter will happen if
49
+ # there isn't actually a real column to get data from, or if there's
50
+ # multiple data values (read: a has_many or has_and_belongs_to_many
51
+ # association).
52
+ #
53
+ def to_group_sql
54
+ case
55
+ when is_many?, is_string?, ThinkingSphinx.use_group_by_shortcut?
56
+ nil
57
+ else
58
+ @columns.collect { |column|
59
+ column_with_prefix(column)
60
+ }
61
+ end
62
+ end
63
+
64
+ def changed?(instance)
65
+ return true if is_string? || @columns.any? { |col| !col.__stack.empty? }
66
+
67
+ @columns.any? { |col|
68
+ instance.send("#{col.__name.to_s}_changed?")
69
+ }
70
+ end
71
+
72
+ def admin?
73
+ admin
74
+ end
75
+
76
+ def public?
77
+ !admin
78
+ end
79
+
80
+ def available?
81
+ columns.any? { |column| column_available?(column) }
82
+ end
83
+
84
+ private
85
+
86
+ # Could there be more than one value related to the parent record? If so,
87
+ # then this will return true. If not, false. It's that simple.
88
+ #
89
+ def is_many?
90
+ associations.values.flatten.any? { |assoc| assoc.is_many? }
91
+ end
92
+
93
+ # Returns true if any of the columns are string values, instead of database
94
+ # column references.
95
+ def is_string?
96
+ columns.all? { |col| col.is_string? }
97
+ end
98
+
99
+ def adapter
100
+ @adapter ||= @model.sphinx_database_adapter
101
+ end
102
+
103
+ def quote_with_table(table, column)
104
+ "#{quote_table_name(table)}.#{quote_column(column)}"
105
+ end
106
+
107
+ def quote_column(column)
108
+ @model.connection.quote_column_name(column)
109
+ end
110
+
111
+ def quote_table_name(table_name)
112
+ @model.connection.quote_table_name(table_name)
113
+ end
114
+
115
+ # Indication of whether the columns should be concatenated with a space
116
+ # between each value. True if there's either multiple sources or multiple
117
+ # associations.
118
+ #
119
+ def concat_ws?
120
+ multiple_associations? || @columns.length > 1
121
+ end
122
+
123
+ # Checks whether any column requires multiple associations (which only
124
+ # happens for polymorphic situations).
125
+ #
126
+ def multiple_associations?
127
+ associations.any? { |col,assocs| assocs.length > 1 }
128
+ end
129
+
130
+ # Builds a column reference tied to the appropriate associations. This
131
+ # dives into the associations hash and their corresponding joins to
132
+ # figure out how to correctly reference a column in SQL.
133
+ #
134
+ def column_with_prefix(column)
135
+ return nil unless column_available?(column)
136
+
137
+ if column.is_string?
138
+ column.__name
139
+ elsif column.__stack.empty?
140
+ "#{@model.quoted_table_name}.#{quote_column(column.__name)}"
141
+ else
142
+ associations[column].collect { |assoc|
143
+ assoc.has_column?(column.__name) ?
144
+ "#{quote_with_table(assoc.join.aliased_table_name, column.__name)}" :
145
+ nil
146
+ }.compact
147
+ end
148
+ end
149
+
150
+ def columns_with_prefixes
151
+ @columns.collect { |column|
152
+ column_with_prefix column
153
+ }.flatten.compact
154
+ end
155
+
156
+ def column_available?(column)
157
+ if column.is_string?
158
+ true
159
+ elsif column.__stack.empty?
160
+ @model.column_names.include?(column.__name.to_s)
161
+ else
162
+ associations[column].any? { |assoc| assoc.has_column?(column.__name) }
163
+ end
164
+ end
165
+
166
+ # Gets a stack of associations for a specific path.
167
+ #
168
+ def association_stack(path, parent = nil)
169
+ assocs = []
170
+
171
+ if parent.nil?
172
+ assocs = @source.association(path.shift)
173
+ else
174
+ assocs = parent.children(path.shift)
175
+ end
176
+
177
+ until path.empty?
178
+ point = path.shift
179
+ assocs = assocs.collect { |assoc| assoc.children(point) }.flatten
180
+ end
181
+
182
+ assocs
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,46 @@
1
+ require 'thinking_sphinx'
2
+ require 'rails'
3
+
4
+ module ThinkingSphinx
5
+ class Railtie < Rails::Railtie
6
+
7
+ initializer 'thinking_sphinx.sphinx' do
8
+ ThinkingSphinx::AutoVersion.detect
9
+ end
10
+
11
+ initializer "thinking_sphinx.active_record" do
12
+ ActiveSupport.on_load :active_record do
13
+ include ThinkingSphinx::ActiveRecord
14
+ end
15
+ end
16
+
17
+ initializer "thinking_sphinx.action_controller" do
18
+ ActiveSupport.on_load :action_controller do
19
+ require 'thinking_sphinx/action_controller'
20
+ include ThinkingSphinx::ActionController
21
+ end
22
+ end
23
+
24
+ initializer "thinking_sphinx.set_app_root" do |app|
25
+ ThinkingSphinx::Configuration.instance.reset # Rails has setup app now
26
+ end
27
+
28
+ config.to_prepare do
29
+ I18n.backend.reload!
30
+ I18n.backend.available_locales
31
+
32
+ # ActiveRecord::Base.to_crc32s is dependant on the subclasses being loaded
33
+ # consistently. When the environment is reset, subclasses/descendants will
34
+ # be lost but our context will not reload them for us.
35
+ #
36
+ # We reset the context which causes the subclasses/descendants to be
37
+ # reloaded next time the context is called.
38
+ #
39
+ ThinkingSphinx.reset_context!
40
+ end
41
+
42
+ rake_tasks do
43
+ load File.expand_path('../tasks.rb', __FILE__)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,950 @@
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 < Array
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?
15
+ respond_to_missing? send should type )
16
+ SafeMethods = %w( partition private_methods protected_methods
17
+ public_methods send class )
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
+ warn '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
+ warn '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
+ warn '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
+ warn '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
+ warn 'ThinkingSphinx::Search.facets is deprecated. Please use ThinkingSphinx.facets instead.'
57
+ ThinkingSphinx.facets(*args)
58
+ end
59
+
60
+ def self.warn(message)
61
+ ::ActiveSupport::Deprecation.warn message
62
+ end
63
+
64
+ def self.bundle_searches(enum = nil)
65
+ bundle = ThinkingSphinx::BundledSearch.new
66
+
67
+ if enum.nil?
68
+ yield bundle
69
+ else
70
+ enum.each { |item| yield bundle, item }
71
+ end
72
+
73
+ bundle.searches
74
+ end
75
+
76
+ def self.matching_fields(fields, bitmask)
77
+ matches = []
78
+ bitstring = bitmask.to_s(2).rjust(32, '0').reverse
79
+
80
+ fields.each_with_index do |field, index|
81
+ matches << field if bitstring[index, 1] == '1'
82
+ end
83
+ matches
84
+ end
85
+
86
+ def initialize(*args)
87
+ ThinkingSphinx.context.define_indexes
88
+
89
+ @array = []
90
+ @options = args.extract_options!
91
+ @args = args
92
+
93
+ add_default_scope unless options[:ignore_default]
94
+
95
+ populate if @options[:populate]
96
+ end
97
+
98
+ def to_a
99
+ populate
100
+ @array
101
+ end
102
+
103
+ def freeze
104
+ populate
105
+ @array.freeze
106
+ self
107
+ end
108
+
109
+ def as_json(*args)
110
+ populate
111
+ @array.as_json(*args)
112
+ end
113
+
114
+ # Indication of whether the request has been made to Sphinx for the search
115
+ # query.
116
+ #
117
+ # @return [Boolean] true if the results have been requested.
118
+ #
119
+ def populated?
120
+ !!@populated
121
+ end
122
+
123
+ # Indication of whether the request resulted in an error from Sphinx.
124
+ #
125
+ # @return [Boolean] true if Sphinx reports query error
126
+ #
127
+ def error?
128
+ !!error
129
+ end
130
+
131
+ # The Sphinx-reported error, if any.
132
+ #
133
+ # @return [String, nil]
134
+ #
135
+ def error
136
+ populate
137
+ @results[:error]
138
+ end
139
+
140
+ # Indication of whether the request resulted in a warning from Sphinx.
141
+ #
142
+ # @return [Boolean] true if Sphinx reports query warning
143
+ #
144
+ def warning?
145
+ !!warning
146
+ end
147
+
148
+ # The Sphinx-reported warning, if any.
149
+ #
150
+ # @return [String, nil]
151
+ #
152
+ def warning
153
+ populate
154
+ @results[:warning]
155
+ end
156
+
157
+ # The query result hash from Riddle.
158
+ #
159
+ # @return [Hash] Raw Sphinx results
160
+ #
161
+ def results
162
+ populate
163
+ @results
164
+ end
165
+
166
+ def method_missing(method, *args, &block)
167
+ if is_scope?(method)
168
+ add_scope(method, *args, &block)
169
+ return self
170
+ elsif method == :search_count
171
+ merge_search one_class.search(*args), self.args, options
172
+ return scoped_count
173
+ elsif method.to_s[/^each_with_.*/].nil? && !@array.respond_to?(method)
174
+ super
175
+ elsif !SafeMethods.include?(method.to_s)
176
+ populate
177
+ end
178
+
179
+ if method.to_s[/^each_with_.*/] && !@array.respond_to?(method)
180
+ each_with_attribute method.to_s.gsub(/^each_with_/, ''), &block
181
+ else
182
+ @array.send(method, *args, &block)
183
+ end
184
+ end
185
+
186
+ # Returns true if the Search object or the underlying Array object respond
187
+ # to the requested method.
188
+ #
189
+ # @param [Symbol] method The method name
190
+ # @return [Boolean] true if either Search or Array responds to the method.
191
+ #
192
+ def respond_to?(method, include_private = false)
193
+ super || @array.respond_to?(method, include_private)
194
+ end
195
+
196
+ # The current page number of the result set. Defaults to 1 if no page was
197
+ # explicitly requested.
198
+ #
199
+ # @return [Integer]
200
+ #
201
+ def current_page
202
+ @options[:page].blank? ? 1 : @options[:page].to_i
203
+ end
204
+
205
+ # Kaminari support
206
+ def page(page_number)
207
+ @options[:page] = page_number
208
+ self
209
+ end
210
+
211
+ # The next page number of the result set. If there are no more pages
212
+ # available, nil is returned.
213
+ #
214
+ # @return [Integer, nil]
215
+ #
216
+ def next_page
217
+ current_page >= total_pages ? nil : current_page + 1
218
+ end
219
+
220
+ # The previous page number of the result set. If this is the first page,
221
+ # then nil is returned.
222
+ #
223
+ # @return [Integer, nil]
224
+ #
225
+ def previous_page
226
+ current_page == 1 ? nil : current_page - 1
227
+ end
228
+
229
+ # The amount of records per set of paged results. Defaults to 20 unless a
230
+ # specific page size is requested.
231
+ #
232
+ # @return [Integer]
233
+ #
234
+ def per_page
235
+ @options[:limit] ||= @options[:per_page]
236
+ @options[:limit] ||= 20
237
+ @options[:limit].to_i
238
+ end
239
+ # Kaminari support
240
+ alias_method :limit_value, :per_page
241
+
242
+ # Kaminari support
243
+ def per(limit)
244
+ @options[:limit] = limit
245
+ self
246
+ end
247
+
248
+ # The total number of pages available if the results are paginated.
249
+ #
250
+ # @return [Integer]
251
+ #
252
+ def total_pages
253
+ populate
254
+ return 0 if @results[:total].nil?
255
+
256
+ @total_pages ||= (@results[:total] / per_page.to_f).ceil
257
+ end
258
+ # Compatibility with kaminari and older versions of will_paginate
259
+ alias_method :page_count, :total_pages
260
+ alias_method :num_pages, :total_pages
261
+
262
+ # Query time taken
263
+ #
264
+ # @return [Integer]
265
+ #
266
+ def query_time
267
+ populate
268
+ return 0 if @results[:time].nil?
269
+
270
+ @query_time ||= @results[:time]
271
+ end
272
+
273
+ # The total number of search results available.
274
+ #
275
+ # @return [Integer]
276
+ #
277
+ def total_entries
278
+ populate
279
+ return 0 if @results[:total_found].nil?
280
+
281
+ @total_entries ||= @results[:total_found]
282
+ end
283
+
284
+ # The current page's offset, based on the number of records per page.
285
+ # Or explicit :offset if given.
286
+ #
287
+ # @return [Integer]
288
+ #
289
+ def offset
290
+ @options[:offset] || ((current_page - 1) * per_page)
291
+ end
292
+
293
+ def indexes
294
+ return options[:index] if options[:index]
295
+ return '*' if classes.empty?
296
+
297
+ classes.collect { |klass|
298
+ klass.sphinx_index_names
299
+ }.flatten.uniq.join(',')
300
+ end
301
+
302
+ def each_with_groupby_and_count(&block)
303
+ populate
304
+ results[:matches].each_with_index do |match, index|
305
+ yield self[index],
306
+ match[:attributes]["@groupby"],
307
+ match[:attributes]["@count"]
308
+ end
309
+ end
310
+ alias_method :each_with_group_and_count, :each_with_groupby_and_count
311
+
312
+ def each_with_weighting(&block)
313
+ populate
314
+ results[:matches].each_with_index do |match, index|
315
+ yield self[index], match[:weight]
316
+ end
317
+ end
318
+
319
+ def each_with_match(&block)
320
+ populate
321
+ results[:matches].each_with_index do |match, index|
322
+ yield self[index], match
323
+ end
324
+ end
325
+
326
+ def excerpt_for(string, model = nil)
327
+ if model.nil? && one_class
328
+ model ||= one_class
329
+ end
330
+
331
+ populate
332
+ client.excerpts(
333
+ {
334
+ :docs => [string.to_s],
335
+ :words => results[:words].keys.join(' '),
336
+ :index => options[:index] || "#{model.core_index_names.first}"
337
+ }.merge(options[:excerpt_options] || {})
338
+ ).first
339
+ end
340
+
341
+ def search(*args)
342
+ args << args.extract_options!.merge(:ignore_default => true)
343
+ merge_search ThinkingSphinx::Search.new(*args), self.args, options
344
+ self
345
+ end
346
+
347
+ def search_for_ids(*args)
348
+ args << args.extract_options!.merge(
349
+ :ignore_default => true,
350
+ :ids_only => true
351
+ )
352
+ merge_search ThinkingSphinx::Search.new(*args), self.args, options
353
+ self
354
+ end
355
+
356
+ def facets(*args)
357
+ options = args.extract_options!
358
+ merge_search self, args, options
359
+ args << options
360
+
361
+ ThinkingSphinx::FacetSearch.new(*args)
362
+ end
363
+
364
+ def client
365
+ client = options[:client] || config.client
366
+
367
+ prepare client
368
+ end
369
+
370
+ def append_to(client)
371
+ prepare client
372
+ client.append_query query, indexes, comment
373
+ client.reset
374
+ end
375
+
376
+ def populate_from_queue(results)
377
+ return if @populated
378
+ @populated = true
379
+ @results = results
380
+
381
+ compose_results
382
+ end
383
+
384
+ private
385
+
386
+ def config
387
+ ThinkingSphinx::Configuration.instance
388
+ end
389
+
390
+ def populate
391
+ return if @populated
392
+ @populated = true
393
+
394
+ retry_on_stale_index do
395
+ begin
396
+ log query do
397
+ @results = client.query query, indexes, comment
398
+ end
399
+ total = @results[:total_found].to_i
400
+ log "Found #{total} result#{'s' unless total == 1}"
401
+
402
+ log "Sphinx Daemon returned warning: #{warning}" if warning?
403
+
404
+ if error?
405
+ log "Sphinx Daemon returned error: #{error}"
406
+ raise SphinxError.new(error, @results) unless options[:ignore_errors]
407
+ end
408
+ rescue Errno::ECONNREFUSED => err
409
+ raise ThinkingSphinx::ConnectionError,
410
+ 'Connection to Sphinx Daemon (searchd) failed.'
411
+ end
412
+
413
+ compose_results
414
+ end
415
+ end
416
+
417
+ def compose_results
418
+ if options[:ids_only]
419
+ compose_ids_results
420
+ elsif options[:only]
421
+ compose_only_results
422
+ else
423
+ replace instances_from_matches
424
+ add_excerpter
425
+ add_sphinx_attributes
426
+ add_matching_fields if client.rank_mode == :fieldmask
427
+ end
428
+ end
429
+
430
+ def compose_ids_results
431
+ replace @results[:matches].collect { |match|
432
+ match[:attributes]['sphinx_internal_id']
433
+ }
434
+ end
435
+
436
+ def compose_only_results
437
+ replace @results[:matches].collect { |match|
438
+ case only = options[:only]
439
+ when String, Symbol
440
+ match[:attributes][only.to_s]
441
+ when Array
442
+ only.inject({}) do |hash, attribute|
443
+ hash[attribute.to_sym] = match[:attributes][attribute.to_s]
444
+ hash
445
+ end
446
+ else
447
+ raise "Unexpected object for :only argument. String or Array is expected, #{only.class} was received."
448
+ end
449
+ }
450
+ end
451
+
452
+ def add_excerpter
453
+ each do |object|
454
+ next if object.nil?
455
+
456
+ object.excerpts = ThinkingSphinx::Excerpter.new self, object
457
+ end
458
+ end
459
+
460
+ def add_sphinx_attributes
461
+ each do |object|
462
+ next if object.nil?
463
+
464
+ match = match_hash object
465
+ next if match.nil?
466
+
467
+ object.sphinx_attributes = match[:attributes]
468
+ end
469
+ end
470
+
471
+ def add_matching_fields
472
+ each do |object|
473
+ next if object.nil?
474
+
475
+ match = match_hash object
476
+ next if match.nil?
477
+ object.matching_fields = ThinkingSphinx::Search.matching_fields(
478
+ @results[:fields], match[:weight]
479
+ )
480
+ end
481
+ end
482
+
483
+ def match_hash(object)
484
+ @results[:matches].detect { |match|
485
+ match[:attributes]['sphinx_internal_id'] == object.
486
+ primary_key_for_sphinx &&
487
+ match[:attributes]['class_crc'] == object.class.to_crc32
488
+ }
489
+ end
490
+
491
+ def self.log(message, &block)
492
+ if ThinkingSphinx::ActiveRecord::LogSubscriber.logger.nil?
493
+ yield if block_given?
494
+ return
495
+ end
496
+
497
+ if block_given?
498
+ ::ActiveSupport::Notifications.
499
+ instrument('query.thinking_sphinx', :query => message, &block)
500
+ else
501
+ ::ActiveSupport::Notifications.
502
+ instrument('message.thinking_sphinx', :message => message)
503
+ end
504
+ end
505
+
506
+ def log(query, &block)
507
+ self.class.log(query, &block)
508
+ end
509
+
510
+ def prepare(client)
511
+ index_options = {}
512
+ if one_class && one_class.sphinx_indexes && one_class.sphinx_indexes.first
513
+ index_options = one_class.sphinx_indexes.first.local_options
514
+ end
515
+
516
+ [
517
+ :max_matches, :group_by, :group_function, :group_clause,
518
+ :group_distinct, :id_range, :cut_off, :retry_count, :retry_delay,
519
+ :rank_mode, :max_query_time, :field_weights
520
+ ].each do |key|
521
+ value = options[key] || index_options[key]
522
+ client.send("#{key}=", value) if value
523
+ end
524
+
525
+ # treated non-standard as :select is already used for AR queries
526
+ client.select = options[:sphinx_select] || '*'
527
+
528
+ client.limit = per_page
529
+ client.offset = offset
530
+ client.match_mode = match_mode
531
+ client.filters = filters
532
+ client.sort_mode = sort_mode
533
+ client.sort_by = sort_by
534
+ client.group_by = group_by if group_by
535
+ client.group_function = group_function if group_function
536
+ client.index_weights = index_weights
537
+ client.anchor = anchor
538
+
539
+ client
540
+ end
541
+
542
+ def retry_on_stale_index(&block)
543
+ stale_ids = []
544
+ retries = stale_retries
545
+
546
+ begin
547
+ options[:raise_on_stale] = retries > 0
548
+ block.call
549
+
550
+ # If ThinkingSphinx::Search#instances_from_matches found records in
551
+ # Sphinx but not in the DB and the :raise_on_stale option is set, this
552
+ # exception is raised. We retry a limited number of times, excluding the
553
+ # stale ids from the search.
554
+ rescue StaleIdsException => err
555
+ retries -= 1
556
+
557
+ # For logging
558
+ stale_ids |= err.ids
559
+ # ID exclusion
560
+ options[:without_ids] = Array(options[:without_ids]) | err.ids
561
+
562
+ log 'Stale Ids (%s %s left): %s' % [
563
+ retries, (retries == 1 ? 'try' : 'tries'), stale_ids.join(', ')
564
+ ]
565
+ retry
566
+ end
567
+ end
568
+
569
+ def classes
570
+ @classes ||= options[:classes] || []
571
+ end
572
+
573
+ def one_class
574
+ @one_class ||= classes.length != 1 ? nil : classes.first
575
+ end
576
+
577
+ def query
578
+ @query ||= begin
579
+ q = @args.join(' ') << conditions_as_query
580
+ (options[:star] ? star_query(q) : q).strip
581
+ end
582
+ end
583
+
584
+ def conditions_as_query
585
+ return '' if @options[:conditions].blank?
586
+
587
+ ' ' + @options[:conditions].keys.collect { |key|
588
+ "@#{key} #{options[:conditions][key]}"
589
+ }.join(' ')
590
+ end
591
+
592
+ def star_query(query)
593
+ token = options[:star].is_a?(Regexp) ? options[:star] : /\w+/u
594
+
595
+ query.gsub(/("#{token}(.*?#{token})?"|(?![!-])#{token})/u) do
596
+ pre, proper, post = $`, $&, $'
597
+ # E.g. "@foo", "/2", "~3", but not as part of a token
598
+ is_operator = pre.match(%r{(\W|^)[@~/]\Z}) ||
599
+ pre.match(%r{(\W|^)@\([^\)]*$})
600
+ # E.g. "foo bar", with quotes
601
+ is_quote = proper.starts_with?('"') && proper.ends_with?('"')
602
+ has_star = pre.ends_with?("*") || post.starts_with?("*")
603
+ if is_operator || is_quote || has_star
604
+ proper
605
+ else
606
+ "*#{proper}*"
607
+ end
608
+ end
609
+ end
610
+
611
+ def comment
612
+ options[:comment] || ''
613
+ end
614
+
615
+ def match_mode
616
+ options[:match_mode] || (options[:conditions].blank? ? :all : :extended)
617
+ end
618
+
619
+ def sort_mode
620
+ @sort_mode ||= case options[:sort_mode]
621
+ when :asc
622
+ :attr_asc
623
+ when :desc
624
+ :attr_desc
625
+ when nil
626
+ case options[:order]
627
+ when String
628
+ :extended
629
+ when Symbol
630
+ :attr_asc
631
+ else
632
+ :relevance
633
+ end
634
+ else
635
+ options[:sort_mode]
636
+ end
637
+ end
638
+
639
+ def sort_by
640
+ case @sort_by = (options[:sort_by] || options[:order])
641
+ when String
642
+ sorted_fields_to_attributes(@sort_by.clone)
643
+ when Symbol
644
+ field_names.include?(@sort_by) ?
645
+ @sort_by.to_s.concat('_sort') : @sort_by.to_s
646
+ else
647
+ ''
648
+ end
649
+ end
650
+
651
+ def field_names
652
+ return [] unless one_class
653
+
654
+ one_class.sphinx_indexes.collect { |index|
655
+ index.fields.collect { |field| field.unique_name }
656
+ }.flatten
657
+ end
658
+
659
+ def sorted_fields_to_attributes(order_string)
660
+ field_names.each { |field|
661
+ order_string.gsub!(/(^|\s)#{field}(,?\s|$)/) { |match|
662
+ match.gsub field.to_s, field.to_s.concat("_sort")
663
+ }
664
+ }
665
+
666
+ order_string
667
+ end
668
+
669
+ # Turn :index_weights => { "foo" => 2, User => 1 } into :index_weights =>
670
+ # { "foo" => 2, "user_core" => 1, "user_delta" => 1 }
671
+ #
672
+ def index_weights
673
+ weights = options[:index_weights] || {}
674
+ weights.keys.inject({}) do |hash, key|
675
+ if key.is_a?(Class)
676
+ name = ThinkingSphinx::Index.name_for(key)
677
+ hash["#{name}_core"] = weights[key]
678
+ hash["#{name}_delta"] = weights[key]
679
+ else
680
+ hash[key] = weights[key]
681
+ end
682
+
683
+ hash
684
+ end
685
+ end
686
+
687
+ def group_by
688
+ options[:group] ? options[:group].to_s : nil
689
+ end
690
+
691
+ def group_function
692
+ options[:group] ? :attr : nil
693
+ end
694
+
695
+ def internal_filters
696
+ filters = [Riddle::Client::Filter.new('sphinx_deleted', [0])]
697
+
698
+ class_crcs = classes.collect { |klass|
699
+ klass.to_crc32s
700
+ }.flatten
701
+
702
+ unless class_crcs.empty?
703
+ filters << Riddle::Client::Filter.new('class_crc', class_crcs)
704
+ end
705
+
706
+ filters << Riddle::Client::Filter.new(
707
+ 'sphinx_internal_id', filter_value(options[:without_ids]), true
708
+ ) if options[:without_ids]
709
+
710
+ filters
711
+ end
712
+
713
+ def filters
714
+ internal_filters +
715
+ (options[:with] || {}).collect { |attrib, value|
716
+ Riddle::Client::Filter.new attrib.to_s, filter_value(value)
717
+ } +
718
+ (options[:without] || {}).collect { |attrib, value|
719
+ Riddle::Client::Filter.new attrib.to_s, filter_value(value), true
720
+ } +
721
+ (options[:with_all] || {}).collect { |attrib, values|
722
+ Array(values).collect { |value|
723
+ Riddle::Client::Filter.new attrib.to_s, filter_value(value)
724
+ }
725
+ }.flatten
726
+ end
727
+
728
+ # When passed a Time instance, returns the integer timestamp.
729
+ def filter_value(value)
730
+ case value
731
+ when Range
732
+ filter_value(value.first).first..filter_value(value.last).first
733
+ when Array
734
+ value.collect { |v| filter_value(v) }.flatten
735
+ when Time
736
+ [value.to_i]
737
+ when NilClass
738
+ 0
739
+ else
740
+ Array(value)
741
+ end
742
+ end
743
+
744
+ def anchor
745
+ return {} unless options[:geo] || (options[:lat] && options[:lng])
746
+
747
+ {
748
+ :latitude => options[:geo] ? options[:geo].first : options[:lat],
749
+ :longitude => options[:geo] ? options[:geo].last : options[:lng],
750
+ :latitude_attribute => latitude_attr.to_s,
751
+ :longitude_attribute => longitude_attr.to_s
752
+ }
753
+ end
754
+
755
+ def latitude_attr
756
+ options[:latitude_attr] ||
757
+ index_option(:latitude_attr) ||
758
+ attribute(:lat, :latitude)
759
+ end
760
+
761
+ def longitude_attr
762
+ options[:longitude_attr] ||
763
+ index_option(:longitude_attr) ||
764
+ attribute(:lon, :lng, :longitude)
765
+ end
766
+
767
+ def index_option(key)
768
+ return nil unless one_class
769
+
770
+ one_class.sphinx_indexes.collect { |index|
771
+ index.local_options[key]
772
+ }.compact.first
773
+ end
774
+
775
+ def attribute(*keys)
776
+ return nil unless one_class
777
+
778
+ keys.detect { |key|
779
+ attributes.include?(key)
780
+ }
781
+ end
782
+
783
+ def attributes
784
+ return [] unless one_class
785
+
786
+ attributes = one_class.sphinx_indexes.collect { |index|
787
+ index.attributes.collect { |attrib| attrib.unique_name }
788
+ }.flatten
789
+ end
790
+
791
+ def stale_retries
792
+ case options[:retry_stale]
793
+ when TrueClass
794
+ 3
795
+ when nil, FalseClass
796
+ 0
797
+ else
798
+ options[:retry_stale].to_i
799
+ end
800
+ end
801
+
802
+ def include_for_class(klass)
803
+ includes = options[:include] || klass.sphinx_index_options[:include]
804
+
805
+ case includes
806
+ when NilClass
807
+ nil
808
+ when Array
809
+ include_from_array includes, klass
810
+ when Symbol
811
+ klass.reflections[includes].nil? ? nil : includes
812
+ when Hash
813
+ include_from_hash includes, klass
814
+ else
815
+ includes
816
+ end
817
+ end
818
+
819
+ def include_from_array(array, klass)
820
+ scoped_array = []
821
+ array.each do |value|
822
+ case value
823
+ when Hash
824
+ scoped_hash = include_from_hash(value, klass)
825
+ scoped_array << scoped_hash unless scoped_hash.nil?
826
+ else
827
+ scoped_array << value unless klass.reflections[value].nil?
828
+ end
829
+ end
830
+ scoped_array.empty? ? nil : scoped_array
831
+ end
832
+
833
+ def include_from_hash(hash, klass)
834
+ scoped_hash = {}
835
+ hash.keys.each do |key|
836
+ scoped_hash[key] = hash[key] unless klass.reflections[key].nil?
837
+ end
838
+ scoped_hash.empty? ? nil : scoped_hash
839
+ end
840
+
841
+ def instances_from_class(klass, matches)
842
+ index_options = klass.sphinx_index_options
843
+
844
+ ids = matches.collect { |match| match[:attributes]["sphinx_internal_id"] }
845
+ instances = ids.length > 0 ? klass.find(
846
+ :all,
847
+ :joins => options[:joins],
848
+ :conditions => {klass.primary_key_for_sphinx.to_sym => ids},
849
+ :include => include_for_class(klass),
850
+ :select => (options[:select] || index_options[:select]),
851
+ :order => (options[:sql_order] || index_options[:sql_order]) || "FIELD(id, #{ids.join(',')})"
852
+ ) : []
853
+
854
+ # Raise an exception if we find records in Sphinx but not in the DB, so
855
+ # the search method can retry without them. See
856
+ # ThinkingSphinx::Search.retry_search_on_stale_index.
857
+ if options[:raise_on_stale] && instances.length < ids.length
858
+ stale_ids = ids - instances.map { |i| i.id }
859
+ raise StaleIdsException, stale_ids
860
+ end
861
+
862
+ # if the user has specified an SQL order, return the collection
863
+ # without rearranging it into the Sphinx order
864
+ return instances
865
+ end
866
+
867
+ # Group results by class and call #find(:all) once for each group to reduce
868
+ # the number of #find's in multi-model searches.
869
+ #
870
+ def instances_from_matches
871
+ return single_class_results if one_class
872
+
873
+ groups = results[:matches].group_by { |match|
874
+ match[:attributes]["class_crc"]
875
+ }
876
+ groups.each do |crc, group|
877
+ group.replace(
878
+ instances_from_class(class_from_crc(crc), group)
879
+ )
880
+ end
881
+
882
+ results[:matches].collect do |match|
883
+ groups.detect { |crc, group|
884
+ crc == match[:attributes]["class_crc"]
885
+ }[1].compact.detect { |obj|
886
+ obj.primary_key_for_sphinx == match[:attributes]["sphinx_internal_id"]
887
+ }
888
+ end
889
+ end
890
+
891
+ def single_class_results
892
+ instances_from_class one_class, results[:matches]
893
+ end
894
+
895
+ def class_from_crc(crc)
896
+ config.models_by_crc[crc].constantize
897
+ end
898
+
899
+ def each_with_attribute(attribute, &block)
900
+ populate
901
+ results[:matches].each_with_index do |match, index|
902
+ yield self[index],
903
+ (match[:attributes][attribute] || match[:attributes]["@#{attribute}"])
904
+ end
905
+ end
906
+
907
+ def is_scope?(method)
908
+ one_class && one_class.sphinx_scopes.include?(method)
909
+ end
910
+
911
+ # Adds the default_sphinx_scope if set.
912
+ def add_default_scope
913
+ return unless one_class && one_class.has_default_sphinx_scope?
914
+ add_scope(one_class.get_default_sphinx_scope.to_sym)
915
+ end
916
+
917
+ def add_scope(method, *args, &block)
918
+ method = "#{method}_without_default".to_sym
919
+ merge_search one_class.send(method, *args, &block), self.args, options
920
+ end
921
+
922
+ def merge_search(search, args, options)
923
+ search.args.each { |arg| args << arg }
924
+
925
+ search.options.keys.each do |key|
926
+ if HashOptions.include?(key)
927
+ options[key] ||= {}
928
+ options[key].merge! search.options[key]
929
+ elsif ArrayOptions.include?(key)
930
+ options[key] ||= []
931
+ options[key] += search.options[key]
932
+ options[key].uniq!
933
+ else
934
+ options[key] = search.options[key]
935
+ end
936
+ end
937
+ end
938
+
939
+ def scoped_count
940
+ return self.total_entries if(@options[:ids_only] || @options[:only])
941
+
942
+ @options[:ids_only] = true
943
+ results_count = self.total_entries
944
+ @options[:ids_only] = false
945
+ @populated = false
946
+
947
+ results_count
948
+ end
949
+ end
950
+ end