zipme-thinking-sphinx 1.3.14

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 (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