josh_cutler-thinking-sphinx 1.3.17

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