warp-thinking-sphinx 1.2.12

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