jaikoo-thinking-sphinx 0.9.10

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 (37) hide show
  1. data/LICENCE +20 -0
  2. data/README +76 -0
  3. data/lib/thinking_sphinx.rb +112 -0
  4. data/lib/thinking_sphinx/active_record.rb +153 -0
  5. data/lib/thinking_sphinx/active_record/delta.rb +80 -0
  6. data/lib/thinking_sphinx/active_record/has_many_association.rb +29 -0
  7. data/lib/thinking_sphinx/active_record/search.rb +50 -0
  8. data/lib/thinking_sphinx/adapters/abstract_adapter.rb +27 -0
  9. data/lib/thinking_sphinx/adapters/mysql_adapter.rb +9 -0
  10. data/lib/thinking_sphinx/adapters/postgresql_adapter.rb +84 -0
  11. data/lib/thinking_sphinx/association.rb +144 -0
  12. data/lib/thinking_sphinx/attribute.rb +284 -0
  13. data/lib/thinking_sphinx/collection.rb +105 -0
  14. data/lib/thinking_sphinx/configuration.rb +314 -0
  15. data/lib/thinking_sphinx/field.rb +206 -0
  16. data/lib/thinking_sphinx/index.rb +432 -0
  17. data/lib/thinking_sphinx/index/builder.rb +220 -0
  18. data/lib/thinking_sphinx/index/faux_column.rb +110 -0
  19. data/lib/thinking_sphinx/rails_additions.rb +68 -0
  20. data/lib/thinking_sphinx/search.rb +436 -0
  21. data/spec/unit/thinking_sphinx/active_record/delta_spec.rb +132 -0
  22. data/spec/unit/thinking_sphinx/active_record/has_many_association_spec.rb +53 -0
  23. data/spec/unit/thinking_sphinx/active_record/search_spec.rb +107 -0
  24. data/spec/unit/thinking_sphinx/active_record_spec.rb +295 -0
  25. data/spec/unit/thinking_sphinx/association_spec.rb +247 -0
  26. data/spec/unit/thinking_sphinx/attribute_spec.rb +360 -0
  27. data/spec/unit/thinking_sphinx/collection_spec.rb +71 -0
  28. data/spec/unit/thinking_sphinx/configuration_spec.rb +512 -0
  29. data/spec/unit/thinking_sphinx/field_spec.rb +224 -0
  30. data/spec/unit/thinking_sphinx/index/builder_spec.rb +34 -0
  31. data/spec/unit/thinking_sphinx/index/faux_column_spec.rb +68 -0
  32. data/spec/unit/thinking_sphinx/index_spec.rb +317 -0
  33. data/spec/unit/thinking_sphinx/search_spec.rb +203 -0
  34. data/spec/unit/thinking_sphinx_spec.rb +129 -0
  35. data/tasks/thinking_sphinx_tasks.rake +1 -0
  36. data/tasks/thinking_sphinx_tasks.rb +100 -0
  37. metadata +103 -0
@@ -0,0 +1,105 @@
1
+ module ThinkingSphinx
2
+ class Collection < ::Array
3
+ attr_reader :total_entries, :total_pages, :current_page
4
+ attr_accessor :results
5
+
6
+ def initialize(page, per_page, entries, total_entries)
7
+ @current_page, @per_page, @total_entries = page, per_page, total_entries
8
+
9
+ @total_pages = (entries / @per_page.to_f).ceil
10
+ end
11
+
12
+ def self.ids_from_results(results, page, limit, options)
13
+ collection = self.new(page, limit,
14
+ results[:total] || 0, results[:total_found] || 0
15
+ )
16
+ collection.results = results
17
+ collection.replace results[:matches].collect { |match|
18
+ match[:attributes]["sphinx_internal_id"]
19
+ }
20
+ return collection
21
+ end
22
+
23
+ def self.create_from_results(results, page, limit, options)
24
+ collection = self.new(page, limit,
25
+ results[:total] || 0, results[:total_found] || 0
26
+ )
27
+ collection.results = results
28
+ collection.replace instances_from_matches(results[:matches], options)
29
+ return collection
30
+ end
31
+
32
+ def self.instances_from_matches(matches, options = {})
33
+ return matches.collect { |match|
34
+ instance_from_match match, options
35
+ } unless klass = options[:class]
36
+
37
+ ids = matches.collect { |match| match[:attributes]["sphinx_internal_id"] }
38
+ instances = ids.length > 0 ? klass.find(
39
+ :all,
40
+ :conditions => {klass.primary_key.to_sym => ids},
41
+ :include => options[:include],
42
+ :select => options[:select]
43
+ ) : []
44
+ ids.collect { |obj_id|
45
+ instances.detect { |obj| obj.id == obj_id }
46
+ }
47
+ end
48
+
49
+ def self.instance_from_match(match, options)
50
+ # puts "ARGS: #{match[:attributes]["sphinx_internal_id"].inspect}, {:include => #{options[:include].inspect}, :select => #{options[:select].inspect}}"
51
+ class_from_crc(match[:attributes]["class_crc"]).find(
52
+ match[:attributes]["sphinx_internal_id"],
53
+ :include => options[:include],
54
+ :select => options[:select]
55
+ )
56
+ end
57
+
58
+ def self.class_from_crc(crc)
59
+ @@models_by_crc ||= ThinkingSphinx.indexed_models.inject({}) do |hash, model|
60
+ hash[model.constantize.to_crc32] = model
61
+ model.constantize.subclasses.each { |subclass|
62
+ hash[subclass.to_crc32] = subclass.name
63
+ }
64
+ hash
65
+ end
66
+ @@models_by_crc[crc].constantize
67
+ end
68
+
69
+ def previous_page
70
+ current_page > 1 ? (current_page - 1) : nil
71
+ end
72
+
73
+ def next_page
74
+ current_page < total_pages ? (current_page + 1): nil
75
+ end
76
+
77
+ def offset
78
+ (current_page - 1) * @per_page
79
+ end
80
+
81
+ def method_missing(method, *args, &block)
82
+ super unless method.to_s[/^each_with_.*/]
83
+
84
+ each_with_attribute method.to_s.gsub(/^each_with_/, ''), &block
85
+ end
86
+
87
+ def each_with_group_and_count(&block)
88
+ results[:matches].each_with_index do |match, index|
89
+ yield self[index], match[:attributes]["@group"], match[:attributes]["@count"]
90
+ end
91
+ end
92
+
93
+ def each_with_attribute(attribute, &block)
94
+ results[:matches].each_with_index do |match, index|
95
+ yield self[index], (match[:attributes][attribute] || match[:attributes]["@#{attribute}"])
96
+ end
97
+ end
98
+
99
+ def each_with_weighting(&block)
100
+ results[:matches].each_with_index do |match, index|
101
+ yield self[index], match[:weight]
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,314 @@
1
+ require 'erb'
2
+ require 'singleton'
3
+
4
+ module ThinkingSphinx
5
+ # This class both keeps track of the configuration settings for Sphinx and
6
+ # also generates the resulting file for Sphinx to use.
7
+ #
8
+ # Here are the default settings, relative to RAILS_ROOT where relevant:
9
+ #
10
+ # config file:: config/#{environment}.sphinx.conf
11
+ # searchd log file:: log/searchd.log
12
+ # query log file:: log/searchd.query.log
13
+ # pid file:: log/searchd.#{environment}.pid
14
+ # searchd files:: db/sphinx/#{environment}/
15
+ # address:: 127.0.0.1
16
+ # port:: 3312
17
+ # allow star:: false
18
+ # min prefix length:: 1
19
+ # min infix length:: 1
20
+ # mem limit:: 64M
21
+ # max matches:: 1000
22
+ # morphology:: stem_en
23
+ # charset type:: utf-8
24
+ # charset table:: nil
25
+ # ignore chars:: nil
26
+ # html strip:: false
27
+ # html remove elements:: ''
28
+ #
29
+ # If you want to change these settings, create a YAML file at
30
+ # config/sphinx.yml with settings for each environment, in a similar
31
+ # fashion to database.yml - using the following keys: config_file,
32
+ # searchd_log_file, query_log_file, pid_file, searchd_file_path, port,
33
+ # allow_star, enable_star, min_prefix_len, min_infix_len, mem_limit,
34
+ # max_matches, # morphology, charset_type, charset_table, ignore_chars,
35
+ # html_strip, # html_remove_elements. I think you've got the idea.
36
+ #
37
+ # Each setting in the YAML file is optional - so only put in the ones you
38
+ # want to change.
39
+ #
40
+ # Keep in mind, if for some particular reason you're using a version of
41
+ # Sphinx older than 0.9.8 r871 (that's prior to the proper 0.9.8 release),
42
+ # don't set allow_star to true.
43
+ #
44
+ class Configuration
45
+ include Singleton
46
+
47
+ SourceOptions = %w( mysql_connect_flags sql_range_step sql_query_pre
48
+ sql_query_post sql_ranged_throttle sql_query_post_index )
49
+
50
+ IndexOptions = %w( charset_table charset_type docinfo enable_star
51
+ exceptions html_index_attrs html_remove_elements html_strip ignore_chars
52
+ min_infix_len min_prefix_len min_word_len mlock morphology ngram_chars
53
+ ngram_len phrase_boundary phrase_boundary_step preopen stopwords
54
+ wordforms )
55
+
56
+ IndexerOptions = %w( max_iops max_iosize mem_limit )
57
+
58
+ SearchdOptions = %w( read_timeout max_children max_matches seamless_rotate
59
+ preopen_indexes unlink_old )
60
+
61
+ attr_accessor :config_file, :searchd_log_file, :query_log_file,
62
+ :pid_file, :searchd_file_path, :address, :port, :allow_star,
63
+ :database_yml_file, :app_root, :bin_path
64
+
65
+ attr_accessor :source_options, :index_options, :indexer_options,
66
+ :searchd_options
67
+
68
+ attr_reader :environment
69
+
70
+ # Load in the configuration settings - this will look for config/sphinx.yml
71
+ # and parse it according to the current environment.
72
+ #
73
+ def initialize(app_root = Dir.pwd)
74
+ self.reset
75
+ end
76
+
77
+ def reset
78
+ self.app_root = RAILS_ROOT if defined?(RAILS_ROOT)
79
+ self.app_root = Merb.root if defined?(Merb)
80
+ self.app_root ||= app_root
81
+
82
+ self.database_yml_file = "#{self.app_root}/config/database.yml"
83
+ self.config_file = "#{self.app_root}/config/#{environment}.sphinx.conf"
84
+ self.searchd_log_file = "#{self.app_root}/log/searchd.log"
85
+ self.query_log_file = "#{self.app_root}/log/searchd.query.log"
86
+ self.pid_file = "#{self.app_root}/log/searchd.#{environment}.pid"
87
+ self.searchd_file_path = "#{self.app_root}/db/sphinx/#{environment}"
88
+ self.address = "127.0.0.1"
89
+ self.port = 3312
90
+ self.allow_star = false
91
+ self.bin_path = ""
92
+
93
+ self.source_options = {}
94
+ self.index_options = {
95
+ :charset_type => "utf-8",
96
+ :morphology => "stem_en"
97
+ }
98
+ self.indexer_options = {}
99
+ self.searchd_options = {}
100
+
101
+ parse_config
102
+
103
+ self
104
+ end
105
+
106
+ def self.environment
107
+ @@environment ||= (
108
+ defined?(Merb) ? Merb.environment : ENV['RAILS_ENV']
109
+ ) || "development"
110
+ end
111
+
112
+ def environment
113
+ self.class.environment
114
+ end
115
+
116
+ # Generate the config file for Sphinx by using all the settings defined and
117
+ # looping through all the models with indexes to build the relevant
118
+ # indexer and searchd configuration, and sources and indexes details.
119
+ #
120
+ def build(file_path=nil)
121
+ load_models
122
+ file_path ||= "#{self.config_file}"
123
+ database_confs = YAML::load(ERB.new(IO.read("#{self.database_yml_file}")).result)
124
+ database_confs.symbolize_keys!
125
+ database_conf = database_confs[environment.to_sym]
126
+ database_conf.symbolize_keys!
127
+
128
+ open(file_path, "w") do |file|
129
+ file.write <<-CONFIG
130
+ indexer
131
+ {
132
+ #{hash_to_config(self.indexer_options)}
133
+ }
134
+
135
+ searchd
136
+ {
137
+ address = #{self.address}
138
+ port = #{self.port}
139
+ log = #{self.searchd_log_file}
140
+ query_log = #{self.query_log_file}
141
+ pid_file = #{self.pid_file}
142
+ #{hash_to_config(self.searchd_options)}
143
+ }
144
+ CONFIG
145
+
146
+ ThinkingSphinx.indexed_models.each_with_index do |model, model_index|
147
+ model = model.constantize
148
+ sources = []
149
+ delta_sources = []
150
+ prefixed_fields = []
151
+ infixed_fields = []
152
+
153
+ model.sphinx_indexes.select { |index| index.model == model }.each_with_index do |index, i|
154
+ file.write index.to_config(model, i, database_conf, model_index)
155
+
156
+ index.adapter_object.setup
157
+
158
+ sources << "#{ThinkingSphinx::Index.name(model)}_#{i}_core"
159
+ delta_sources << "#{ThinkingSphinx::Index.name(model)}_#{i}_delta" if index.delta?
160
+ end
161
+
162
+ next if sources.empty?
163
+
164
+ source_list = sources.collect { |s| "source = #{s}" }.join("\n")
165
+ delta_list = delta_sources.collect { |s| "source = #{s}" }.join("\n")
166
+
167
+ file.write core_index_for_model(model, source_list)
168
+ unless delta_list.blank?
169
+ file.write delta_index_for_model(model, delta_list)
170
+ end
171
+
172
+ file.write distributed_index_for_model(model)
173
+ end
174
+ end
175
+ end
176
+
177
+ # Make sure all models are loaded - without reloading any that
178
+ # ActiveRecord::Base is already aware of (otherwise we start to hit some
179
+ # messy dependencies issues).
180
+ #
181
+ def load_models
182
+ base = "#{app_root}/app/models/"
183
+ Dir["#{base}**/*.rb"].each do |file|
184
+ model_name = file.gsub(/^#{base}([\w_\/\\]+)\.rb/, '\1')
185
+
186
+ next if model_name.nil?
187
+ next if ::ActiveRecord::Base.send(:subclasses).detect { |model|
188
+ model.name == model_name
189
+ }
190
+
191
+ begin
192
+ model_name.camelize.constantize
193
+ rescue LoadError
194
+ model_name.gsub!(/.*[\/\\]/, '').nil? ? next : retry
195
+ rescue NameError
196
+ next
197
+ end
198
+ end
199
+ end
200
+
201
+ def hash_to_config(hash)
202
+ hash.collect { |key, value|
203
+ translated_value = case value
204
+ when TrueClass
205
+ "1"
206
+ when FalseClass
207
+ "0"
208
+ when NilClass, ""
209
+ next
210
+ else
211
+ value
212
+ end
213
+ " #{key} = #{translated_value}"
214
+ }.join("\n")
215
+ end
216
+
217
+ def self.options_merge(base, extra)
218
+ base = base.clone
219
+ extra.each do |key, value|
220
+ next if value.nil? || value == ""
221
+ base[key] = value
222
+ end
223
+ base
224
+ end
225
+
226
+ private
227
+
228
+ # Parse the config/sphinx.yml file - if it exists - then use the attribute
229
+ # accessors to set the appropriate values. Nothing too clever.
230
+ #
231
+ def parse_config
232
+ path = "#{app_root}/config/sphinx.yml"
233
+ return unless File.exists?(path)
234
+
235
+ conf = YAML::load(ERB.new(IO.read(path)).result)[environment]
236
+
237
+ conf.each do |key,value|
238
+ self.send("#{key}=", value) if self.methods.include?("#{key}=")
239
+
240
+ self.source_options[key.to_sym] = value if SourceOptions.include?(key.to_s)
241
+ self.index_options[key.to_sym] = value if IndexOptions.include?(key.to_s)
242
+ self.indexer_options[key.to_sym] = value if IndexerOptions.include?(key.to_s)
243
+ self.searchd_options[key.to_sym] = value if SearchdOptions.include?(key.to_s)
244
+ end unless conf.nil?
245
+
246
+ self.bin_path += '/' unless self.bin_path.blank?
247
+ end
248
+
249
+ def core_index_for_model(model, sources)
250
+ output = <<-INDEX
251
+
252
+ index #{ThinkingSphinx::Index.name(model)}_core
253
+ {
254
+ #{sources}
255
+ path = #{self.searchd_file_path}/#{ThinkingSphinx::Index.name(model)}_core
256
+ INDEX
257
+
258
+ unless combined_index_options(model).empty?
259
+ output += hash_to_config(combined_index_options(model))
260
+ end
261
+
262
+ if self.allow_star
263
+ # Ye Olde way of turning on enable_star
264
+ output += " enable_star = 1\n"
265
+ output += " min_prefix_len = #{self.combined_index_options[:min_prefix_len]}\n"
266
+ end
267
+
268
+ unless model.sphinx_indexes.collect(&:prefix_fields).flatten.empty?
269
+ output += " prefix_fields = #{model.sphinx_indexes.collect(&:prefix_fields).flatten.map(&:unique_name).join(', ')}\n"
270
+ else
271
+ output += " prefix_fields = _\n" unless model.sphinx_indexes.collect(&:infix_fields).flatten.empty?
272
+ end
273
+
274
+ unless model.sphinx_indexes.collect(&:infix_fields).flatten.empty?
275
+ output += " infix_fields = #{model.sphinx_indexes.collect(&:infix_fields).flatten.map(&:unique_name).join(', ')}\n"
276
+ else
277
+ output += " infix_fields = -\n" unless model.sphinx_indexes.collect(&:prefix_fields).flatten.empty?
278
+ end
279
+
280
+ output + "\n}\n"
281
+ end
282
+
283
+ def delta_index_for_model(model, sources)
284
+ <<-INDEX
285
+ index #{ThinkingSphinx::Index.name(model)}_delta : #{ThinkingSphinx::Index.name(model)}_core
286
+ {
287
+ #{sources}
288
+ path = #{self.searchd_file_path}/#{ThinkingSphinx::Index.name(model)}_delta
289
+ }
290
+ INDEX
291
+ end
292
+
293
+ def distributed_index_for_model(model)
294
+ sources = ["local = #{ThinkingSphinx::Index.name(model)}_core"]
295
+ if model.sphinx_indexes.any? { |index| index.delta? }
296
+ sources << "local = #{ThinkingSphinx::Index.name(model)}_delta"
297
+ end
298
+
299
+ <<-INDEX
300
+ index #{ThinkingSphinx::Index.name(model)}
301
+ {
302
+ type = distributed
303
+ #{ sources.join("\n ") }
304
+ }
305
+ INDEX
306
+ end
307
+
308
+ def combined_index_options(model)
309
+ model.sphinx_indexes.inject(self.index_options) do |options, index|
310
+ self.class.options_merge(options, index.local_index_options)
311
+ end
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,206 @@
1
+ module ThinkingSphinx
2
+ # Fields - holding the string data which Sphinx indexes for your searches.
3
+ # This class isn't really useful to you unless you're hacking around with the
4
+ # internals of Thinking Sphinx - but hey, don't let that stop you.
5
+ #
6
+ # One key thing to remember - if you're using the field manually to
7
+ # generate SQL statements, you'll need to set the base model, and all the
8
+ # associations. Which can get messy. Use Index.link!, it really helps.
9
+ #
10
+ class Field
11
+ attr_accessor :alias, :columns, :sortable, :associations, :model, :infixes, :prefixes
12
+
13
+ # To create a new field, you'll need to pass in either a single Column
14
+ # or an array of them, and some (optional) options. The columns are
15
+ # references to the data that will make up the field.
16
+ #
17
+ # Valid options are:
18
+ # - :as => :alias_name
19
+ # - :sortable => true
20
+ # - :infixes => true
21
+ # - :prefixes => true
22
+ #
23
+ # Alias is only required in three circumstances: when there's
24
+ # another attribute or field with the same name, when the column name is
25
+ # 'id', or when there's more than one column.
26
+ #
27
+ # Sortable defaults to false - but is quite useful when set to true, as
28
+ # it creates an attribute with the same string value (which Sphinx converts
29
+ # to an integer value), which can be sorted by. Thinking Sphinx is smart
30
+ # enough to realise that when you specify fields in sort statements, you
31
+ # mean their respective attributes.
32
+ #
33
+ # If you have partial matching enabled (ie: enable_star), then you can
34
+ # specify certain fields to have their prefixes and infixes indexed. Keep
35
+ # in mind, though, that Sphinx's default is _all_ fields - so once you
36
+ # highlight a particular field, no other fields in the index will have
37
+ # these partial indexes.
38
+ #
39
+ # Here's some examples:
40
+ #
41
+ # Field.new(
42
+ # Column.new(:name)
43
+ # )
44
+ #
45
+ # Field.new(
46
+ # [Column.new(:first_name), Column.new(:last_name)],
47
+ # :as => :name, :sortable => true
48
+ # )
49
+ #
50
+ # Field.new(
51
+ # [Column.new(:posts, :subject), Column.new(:posts, :content)],
52
+ # :as => :posts, :prefixes => true
53
+ # )
54
+ #
55
+ def initialize(columns, options = {})
56
+ @columns = Array(columns)
57
+ @associations = {}
58
+
59
+ raise "Cannot define a field 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) }
60
+
61
+ @alias = options[:as]
62
+ @sortable = options[:sortable] || false
63
+ @infixes = options[:infixes] || false
64
+ @prefixes = options[:prefixes] || false
65
+ end
66
+
67
+ # Get the part of the SELECT clause related to this field. Don't forget
68
+ # to set your model and associations first though.
69
+ #
70
+ # This will concatenate strings if there's more than one data source or
71
+ # multiple data values (has_many or has_and_belongs_to_many associations).
72
+ #
73
+ def to_select_sql
74
+ clause = @columns.collect { |column|
75
+ column_with_prefix(column)
76
+ }.join(', ')
77
+
78
+ clause = concatenate(clause) if concat_ws?
79
+ clause = group_concatenate(clause) if is_many?
80
+
81
+ "#{cast_to_string clause } AS #{quote_column(unique_name)}"
82
+ end
83
+
84
+ # Get the part of the GROUP BY clause related to this field - if one is
85
+ # needed. If not, all you'll get back is nil. The latter will happen if
86
+ # there's multiple data values (read: a has_many or has_and_belongs_to_many
87
+ # association).
88
+ #
89
+ def to_group_sql
90
+ case
91
+ when is_many?, ThinkingSphinx.use_group_by_shortcut?
92
+ nil
93
+ else
94
+ @columns.collect { |column|
95
+ column_with_prefix(column)
96
+ }
97
+ end
98
+ end
99
+
100
+ # Returns the unique name of the field - which is either the alias of
101
+ # the field, or the name of the only column - if there is only one. If
102
+ # there isn't, there should be an alias. Else things probably won't work.
103
+ # Consider yourself warned.
104
+ #
105
+ def unique_name
106
+ if @columns.length == 1
107
+ @alias || @columns.first.__name
108
+ else
109
+ @alias
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ def concatenate(clause)
116
+ case @model.connection.class.name
117
+ when "ActiveRecord::ConnectionAdapters::MysqlAdapter"
118
+ "CONCAT_WS(' ', #{clause})"
119
+ when "ActiveRecord::ConnectionAdapters::PostgreSQLAdapter"
120
+ clause.split(', ').join(" || ' ' || ")
121
+ else
122
+ clause
123
+ end
124
+ end
125
+
126
+ def group_concatenate(clause)
127
+ case @model.connection.class.name
128
+ when "ActiveRecord::ConnectionAdapters::MysqlAdapter"
129
+ "GROUP_CONCAT(#{clause} SEPARATOR ' ')"
130
+ when "ActiveRecord::ConnectionAdapters::PostgreSQLAdapter"
131
+ "array_to_string(array_accum(#{clause}), ' ')"
132
+ else
133
+ clause
134
+ end
135
+ end
136
+
137
+ def cast_to_string(clause)
138
+ case @model.connection.class.name
139
+ when "ActiveRecord::ConnectionAdapters::MysqlAdapter"
140
+ "CAST(#{clause} AS CHAR)"
141
+ when "ActiveRecord::ConnectionAdapters::PostgreSQLAdapter"
142
+ clause
143
+ else
144
+ clause
145
+ end
146
+ end
147
+
148
+ def quote_column(column)
149
+ @model.connection.quote_column_name(column)
150
+ end
151
+
152
+ # Indication of whether the columns should be concatenated with a space
153
+ # between each value. True if there's either multiple sources or multiple
154
+ # associations.
155
+ #
156
+ def concat_ws?
157
+ @columns.length > 1 || multiple_associations?
158
+ end
159
+
160
+ # Checks the association tree for each column - if they're all the same,
161
+ # returns false.
162
+ #
163
+ def multiple_sources?
164
+ first = associations[@columns.first]
165
+
166
+ !@columns.all? { |col| associations[col] == first }
167
+ end
168
+
169
+ # Checks whether any column requires multiple associations (which only
170
+ # happens for polymorphic situations).
171
+ #
172
+ def multiple_associations?
173
+ associations.any? { |col,assocs| assocs.length > 1 }
174
+ end
175
+
176
+ # Builds a column reference tied to the appropriate associations. This
177
+ # dives into the associations hash and their corresponding joins to
178
+ # figure out how to correctly reference a column in SQL.
179
+ #
180
+ def column_with_prefix(column)
181
+ if column.is_string?
182
+ column.__name
183
+ elsif associations[column].empty?
184
+ "#{@model.quoted_table_name}.#{quote_column(column.__name)}"
185
+ else
186
+ associations[column].collect { |assoc|
187
+ assoc.has_column?(column.__name) ?
188
+ "#{@model.connection.quote_table_name(assoc.join.aliased_table_name)}" +
189
+ ".#{quote_column(column.__name)}" :
190
+ nil
191
+ }.compact.join(', ')
192
+ end
193
+ end
194
+
195
+ # Could there be more than one value related to the parent record? If so,
196
+ # then this will return true. If not, false. It's that simple.
197
+ #
198
+ def is_many?
199
+ associations.values.flatten.any? { |assoc| assoc.is_many? }
200
+ end
201
+
202
+ def is_string?
203
+ columns.all? { |col| col.is_string? }
204
+ end
205
+ end
206
+ end