jaikoo-thinking-sphinx 0.9.10

Sign up to get free protection for your applications and to get access to all the features.
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