zipme-thinking-sphinx 1.3.14

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