sunspot 2.1.1 → 2.2.0

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 (47) hide show
  1. data/lib/sunspot.rb +13 -9
  2. data/lib/sunspot/dsl.rb +4 -3
  3. data/lib/sunspot/dsl/fields.rb +11 -16
  4. data/lib/sunspot/dsl/paginatable.rb +4 -1
  5. data/lib/sunspot/dsl/spellcheckable.rb +14 -0
  6. data/lib/sunspot/dsl/standard_query.rb +63 -35
  7. data/lib/sunspot/field.rb +54 -8
  8. data/lib/sunspot/field_factory.rb +2 -4
  9. data/lib/sunspot/indexer.rb +1 -2
  10. data/lib/sunspot/query.rb +2 -2
  11. data/lib/sunspot/query/abstract_fulltext.rb +69 -0
  12. data/lib/sunspot/query/common_query.rb +13 -2
  13. data/lib/sunspot/query/composite_fulltext.rb +58 -8
  14. data/lib/sunspot/query/dismax.rb +14 -67
  15. data/lib/sunspot/query/function_query.rb +1 -2
  16. data/lib/sunspot/query/geo.rb +1 -1
  17. data/lib/sunspot/query/join.rb +90 -0
  18. data/lib/sunspot/query/pagination.rb +12 -4
  19. data/lib/sunspot/query/restriction.rb +3 -4
  20. data/lib/sunspot/query/sort.rb +6 -0
  21. data/lib/sunspot/query/sort_composite.rb +7 -0
  22. data/lib/sunspot/query/spellcheck.rb +19 -0
  23. data/lib/sunspot/query/standard_query.rb +24 -2
  24. data/lib/sunspot/query/text_field_boost.rb +1 -3
  25. data/lib/sunspot/search/abstract_search.rb +10 -1
  26. data/lib/sunspot/search/cursor_paginated_collection.rb +32 -0
  27. data/lib/sunspot/search/paginated_collection.rb +1 -0
  28. data/lib/sunspot/search/standard_search.rb +71 -3
  29. data/lib/sunspot/session.rb +6 -6
  30. data/lib/sunspot/setup.rb +6 -1
  31. data/lib/sunspot/util.rb +46 -13
  32. data/lib/sunspot/version.rb +1 -1
  33. data/spec/api/query/fulltext_examples.rb +150 -1
  34. data/spec/api/query/geo_examples.rb +2 -6
  35. data/spec/api/query/join_spec.rb +3 -3
  36. data/spec/api/query/ordering_pagination_examples.rb +14 -0
  37. data/spec/api/query/spellcheck_examples.rb +20 -0
  38. data/spec/api/query/standard_spec.rb +1 -0
  39. data/spec/api/search/cursor_paginated_collection_spec.rb +35 -0
  40. data/spec/api/search/paginated_collection_spec.rb +1 -0
  41. data/spec/api/session_spec.rb +36 -2
  42. data/spec/integration/spellcheck_spec.rb +74 -0
  43. data/spec/mocks/connection.rb +5 -3
  44. data/spec/mocks/photo.rb +12 -4
  45. data/spec/spec_helper.rb +4 -0
  46. metadata +24 -5
  47. checksums.yaml +0 -7
@@ -196,23 +196,27 @@ module Sunspot
196
196
  session.index!(*objects)
197
197
  end
198
198
 
199
- # Commits the singleton session
199
+ # Commits (soft or hard) the singleton session
200
200
  #
201
201
  # When documents are added to or removed from Solr, the changes are
202
202
  # initially stored in memory, and are not reflected in Solr's existing
203
- # searcher instance. When a commit message is sent, the changes are written
203
+ # searcher instance. When a hard commit message is sent, the changes are written
204
204
  # to disk, and a new searcher is spawned. Commits are thus fairly
205
205
  # expensive, so if your application needs to index several documents as part
206
206
  # of a single operation, it is advisable to index them all and then call
207
207
  # commit at the end of the operation.
208
+ # Solr 4 introduced the concept of a soft commit which is much faster
209
+ # since it only makes index changes visible while not writing changes to disk.
210
+ # If Solr crashes or there is a loss of power, changes that occurred after
211
+ # the last hard commit will be lost.
208
212
  #
209
213
  # Note that Solr can also be configured to automatically perform a commit
210
214
  # after either a specified interval after the last change, or after a
211
215
  # specified number of documents are added. See
212
216
  # http://wiki.apache.org/solr/SolrConfigXml
213
217
  #
214
- def commit
215
- session.commit
218
+ def commit(soft_commit = false)
219
+ session.commit soft_commit
216
220
  end
217
221
 
218
222
  # Optimizes the index on the singletion session.
@@ -510,10 +514,10 @@ module Sunspot
510
514
  end
511
515
 
512
516
  #
513
- # Sends a commit if the session is dirty (see #dirty?).
517
+ # Sends a commit (soft or hard) if the session is dirty (see #dirty?).
514
518
  #
515
- def commit_if_dirty
516
- session.commit_if_dirty
519
+ def commit_if_dirty(soft_commit = false)
520
+ session.commit_if_dirty soft_commit
517
521
  end
518
522
 
519
523
  #
@@ -530,8 +534,8 @@ module Sunspot
530
534
  #
531
535
  # Sends a commit if the session has deletes since the last commit (see #delete_dirty?).
532
536
  #
533
- def commit_if_delete_dirty
534
- session.commit_if_delete_dirty
537
+ def commit_if_delete_dirty(soft_commit = false)
538
+ session.commit_if_delete_dirty soft_commit
535
539
  end
536
540
 
537
541
  # Returns the configuration associated with the singleton session. See
@@ -1,5 +1,6 @@
1
- %w(fields scope paginatable adjustable field_query standard_query query_facet
2
- functional fulltext restriction restriction_with_near search
3
- more_like_this_query function field_group field_stats).each do |file|
1
+ %w(spellcheckable fields scope paginatable adjustable field_query
2
+ standard_query query_facet functional fulltext restriction
3
+ restriction_with_near search more_like_this_query function
4
+ field_group field_stats).each do |file|
4
5
  require File.join(File.dirname(__FILE__), 'dsl', file)
5
6
  end
@@ -33,13 +33,10 @@ module Sunspot
33
33
  # DSL::Fulltext#boost_fields method.
34
34
  #
35
35
  def text(*names, &block)
36
- options = names.pop if names.last.is_a?(Hash)
36
+ options = names.last.is_a?(Hash) ? names.pop : {}
37
+
37
38
  names.each do |name|
38
- @setup.add_text_field_factory(
39
- name,
40
- options || {},
41
- &block
42
- )
39
+ @setup.add_text_field_factory(name, options, &block)
43
40
  end
44
41
  end
45
42
 
@@ -74,35 +71,33 @@ module Sunspot
74
71
  #
75
72
  def method_missing(method, *args, &block)
76
73
  options = Util.extract_options_from(args)
77
- if method.to_s == 'join'
78
- type_string = options.delete(:type).to_s
79
- else
80
- type_string = method.to_s
81
- end
74
+ join = method.to_s == 'join'
75
+ type_string = join ? options.delete(:type).to_s : method.to_s
82
76
  type_const_name = "#{Util.camel_case(type_string.sub(/^dynamic_/, ''))}Type"
83
77
  trie = options.delete(:trie)
84
78
  type_const_name = "Trie#{type_const_name}" if trie
79
+
85
80
  begin
86
-
87
- type_class = options[:type]
88
81
  type_class = Type.const_get(type_const_name)
89
- rescue(NameError)
82
+ rescue NameError
90
83
  if trie
91
84
  raise ArgumentError, "Trie fields are only valid for numeric and time types"
92
85
  else
93
86
  super(method, *args, &block)
94
87
  end
95
88
  end
89
+
96
90
  type = type_class.instance
97
91
  name = args.shift
92
+
98
93
  if method.to_s =~ /^dynamic_/
99
94
  if type.accepts_dynamic?
100
95
  @setup.add_dynamic_field_factory(name, type, options, &block)
101
96
  else
102
97
  super(method, *args, &block)
103
98
  end
104
- elsif method.to_s == 'join'
105
- @setup.add_join_field_factory(name, type, options, &block)
99
+ elsif join
100
+ @setup.add_join_field_factory(name, type, options.merge(:clazz => @setup.clazz), &block)
106
101
  else
107
102
  @setup.add_field_factory(name, type, options, &block)
108
103
  end
@@ -20,12 +20,15 @@ module Sunspot
20
20
  # :offset<Integer,String>::
21
21
  # Applies a shift to paginated records. The default is 0.
22
22
  #
23
+ # :cursor<String>:: Cursor value for cursor-based pagination. The default is nil.
24
+ #
23
25
  def paginate(options = {})
24
26
  page = options.delete(:page)
25
27
  per_page = options.delete(:per_page)
26
28
  offset = options.delete(:offset)
29
+ cursor = options.delete(:cursor)
27
30
  raise ArgumentError, "unknown argument #{options.keys.first.inspect} passed to paginate" unless options.empty?
28
- @query.paginate(page, per_page, offset)
31
+ @query.paginate(page, per_page, offset, cursor)
29
32
  end
30
33
  end
31
34
  end
@@ -0,0 +1,14 @@
1
+ module Sunspot
2
+ module DSL #:nodoc:
3
+ module Spellcheckable #:nodoc
4
+ # Ask Solr to suggest alternative spellings for the query
5
+ #
6
+ # ==== Options
7
+ #
8
+ # The list of options can be found here: http://wiki.apache.org/solr/SpellCheckComponent
9
+ def spellcheck(options = {})
10
+ @query.add_spellcheck(options)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -9,7 +9,7 @@ module Sunspot
9
9
  # See Sunspot.search for usage examples
10
10
  #
11
11
  class StandardQuery < FieldQuery
12
- include Paginatable, Adjustable
12
+ include Paginatable, Adjustable, Spellcheckable
13
13
 
14
14
  # Specify a phrase that should be searched as fulltext. Only +text+
15
15
  # fields are searched - see DSL::Fields.text
@@ -56,67 +56,95 @@ module Sunspot
56
56
  # a pizza" will not. Default behavior is a query phrase slop of zero.
57
57
  #
58
58
  def fulltext(keywords, options = {}, &block)
59
- if keywords && !(keywords.to_s =~ /^\s*$/)
60
- fulltext_query = @query.add_fulltext(keywords)
61
- if field_names = options.delete(:fields)
62
- Util.Array(field_names).each do |field_name|
63
- @setup.text_fields(field_name).each do |field|
64
- fulltext_query.add_fulltext_field(field, field.default_boost)
65
- end
66
- end
67
- end
68
- if minimum_match = options.delete(:minimum_match)
69
- fulltext_query.minimum_match = minimum_match.to_i
70
- end
71
- if tie = options.delete(:tie)
72
- fulltext_query.tie = tie.to_f
73
- end
74
- if query_phrase_slop = options.delete(:query_phrase_slop)
75
- fulltext_query.query_phrase_slop = query_phrase_slop.to_i
76
- end
59
+ return if not keywords or keywords.to_s =~ /^\s*$/
60
+
61
+ field_names = Util.Array(options.delete(:fields)).compact
62
+
63
+ add_fulltext(keywords, field_names) do |query, fields|
64
+ query.minimum_match = options.delete(:minimum_match).to_i if options.key?(:minimum_match)
65
+ query.tie = options.delete(:tie).to_f if options.key?(:tie)
66
+ query.query_phrase_slop = options.delete(:query_phrase_slop).to_i if options.key?(:query_phrase_slop)
67
+
77
68
  if highlight_field_names = options.delete(:highlight)
78
69
  if highlight_field_names == true
79
- fulltext_query.add_highlight
70
+ query.add_highlight
80
71
  else
81
72
  highlight_fields = []
82
73
  Util.Array(highlight_field_names).each do |field_name|
83
74
  highlight_fields.concat(@setup.text_fields(field_name))
84
75
  end
85
- fulltext_query.add_highlight(highlight_fields)
76
+ query.add_highlight(highlight_fields)
86
77
  end
87
78
  end
88
- if block && fulltext_query
89
- fulltext_dsl = Fulltext.new(fulltext_query, @setup)
90
- Util.instance_eval_or_call(
91
- fulltext_dsl,
92
- &block
93
- )
79
+
80
+ if block && query
81
+ fulltext_dsl = Fulltext.new(query, @setup)
82
+ Util.instance_eval_or_call(fulltext_dsl, &block)
83
+ else
84
+ fulltext_dsl = nil
94
85
  end
95
- if !field_names && (!fulltext_dsl || !fulltext_dsl.fields_added?)
86
+
87
+ if fields.empty? && (!fulltext_dsl || !fulltext_dsl.fields_added?)
96
88
  @setup.all_text_fields.each do |field|
97
- unless fulltext_query.has_fulltext_field?(field)
89
+ unless query.has_fulltext_field?(field)
98
90
  unless fulltext_dsl && fulltext_dsl.exclude_fields.include?(field.name)
99
- fulltext_query.add_fulltext_field(field, field.default_boost)
91
+ query.add_fulltext_field(field, field.default_boost)
100
92
  end
101
93
  end
102
94
  end
103
95
  end
104
96
  end
105
97
  end
98
+
106
99
  alias_method :keywords, :fulltext
107
100
 
108
101
  def with(*args)
109
102
  case args.first
110
- when String, Symbol
111
- if args.length == 1 # NONE
112
- field = @setup.field(args[0].to_sym)
113
- return DSL::RestrictionWithNear.new(field, @scope, @query, false)
114
- end
103
+ when String, Symbol
104
+ if args.length == 1 # NONE
105
+ field = @setup.field(args[0].to_sym)
106
+ return DSL::RestrictionWithNear.new(field, @scope, @query, false)
107
+ end
115
108
  end
116
109
 
117
110
  # else
118
111
  super
119
112
  end
113
+
114
+ def any(&block)
115
+ @query.disjunction do
116
+ Util.instance_eval_or_call(self, &block)
117
+ end
118
+ end
119
+
120
+ def all(&block)
121
+ @query.conjunction do
122
+ Util.instance_eval_or_call(self, &block)
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def add_fulltext(keywords, field_names)
129
+ return yield(@query.add_fulltext(keywords), []) unless field_names.any?
130
+
131
+ all_fields = field_names.map { |name| @setup.text_fields(name) }.flatten
132
+ all_fields -= join_fields = all_fields.find_all(&:joined?)
133
+
134
+ if all_fields.any?
135
+ fulltext_query = @query.add_fulltext(keywords)
136
+ all_fields.each { |field| fulltext_query.add_fulltext_field(field, field.default_boost) }
137
+ yield(fulltext_query, all_fields)
138
+ end
139
+
140
+ if join_fields.any?
141
+ join_fields.group_by { |field| [field.target, field.from, field.to] }.each_pair do |(target, from, to), fields|
142
+ join_query = @query.add_join(keywords, target, from, to)
143
+ fields.each { |field| join_query.add_fulltext_field(field, field.default_boost) }
144
+ yield(join_query, fields)
145
+ end
146
+ end
147
+ end
120
148
  end
121
149
  end
122
150
  end
@@ -82,17 +82,35 @@ module Sunspot
82
82
  !!@more_like_this
83
83
  end
84
84
 
85
+ #
86
+ # Whether the field was joined from another model.
87
+ #
88
+ # ==== Returns
89
+ #
90
+ # Boolean:: True if this field was joined from another model
91
+ #
92
+ def joined?
93
+ !!@joined
94
+ end
95
+
85
96
  def hash
86
97
  indexed_name.hash
87
98
  end
88
99
 
89
100
  def eql?(field)
90
- indexed_name == field.indexed_name
101
+ field.is_a?(self.class) && indexed_name == field.indexed_name
91
102
  end
92
103
  alias_method :==, :eql?
93
104
 
94
105
  private
95
106
 
107
+ #
108
+ # Raise if an unknown option passed
109
+ #
110
+ def check_options(options)
111
+ raise ArgumentError, "Unknown field option #{options.keys.first.inspect} provided for field #{name.inspect}" unless options.empty?
112
+ end
113
+
96
114
  #
97
115
  # Determine the indexed name. If the :as option is given use that, otherwise
98
116
  # create the value based on the indexed_name of the type with additional
@@ -107,7 +125,8 @@ module Sunspot
107
125
  if options[:as]
108
126
  options.delete(:as).to_s
109
127
  else
110
- "#{@type.indexed_name(@name).to_s}#{'m' if multiple? }#{'s' if @stored}#{'v' if more_like_this?}"
128
+ name = options[:prefix] ? @name.to_s.sub(/^#{options[:prefix]}_/, '') : @name
129
+ "#{@type.indexed_name(name)}#{'m' if multiple? }#{'s' if @stored}#{'v' if more_like_this?}"
111
130
  end
112
131
  end
113
132
 
@@ -129,7 +148,8 @@ module Sunspot
129
148
  @multiple = true
130
149
  @boost = options.delete(:boost)
131
150
  @default_boost = options.delete(:default_boost)
132
- raise ArgumentError, "Unknown field option #{options.keys.first.inspect} provided for field #{name.inspect}" unless options.empty?
151
+
152
+ check_options(options)
133
153
  end
134
154
 
135
155
  def indexed_name
@@ -154,24 +174,50 @@ module Sunspot
154
174
  elsif reference.respond_to?(:to_sym)
155
175
  reference.to_sym
156
176
  end
157
- raise ArgumentError, "Unknown field option #{options.keys.first.inspect} provided for field #{name.inspect}" unless options.empty?
158
- end
159
177
 
178
+ check_options(options)
179
+ end
160
180
  end
161
181
 
182
+ #
183
+ # JoinField encapsulates attributes from referenced models.
184
+ # Could be of any type
185
+ #
162
186
  class JoinField < Field #:nodoc:
187
+ attr_reader :default_boost, :target
163
188
 
164
189
  def initialize(name, type, options = {})
165
190
  @multiple = !!options.delete(:multiple)
191
+
166
192
  super(name, type, options)
167
- @join_string = options.delete(:join_string)
168
- raise ArgumentError, "Unknown field option #{options.keys.first.inspect} provided for field #{name.inspect}" unless options.empty?
193
+
194
+ @prefix = options.delete(:prefix)
195
+ @join = options.delete(:join)
196
+ @clazz = options.delete(:clazz)
197
+ @target = options.delete(:target)
198
+ @default_boost = options.delete(:default_boost)
199
+ @joined = true
200
+
201
+ check_options(options)
202
+ end
203
+
204
+ def from
205
+ Sunspot::Setup.for(@target).field(@join[:from]).indexed_name
206
+ end
207
+
208
+ def to
209
+ Sunspot::Setup.for(@clazz).field(@join[:to]).indexed_name
169
210
  end
170
211
 
171
212
  def local_params
172
- "{!join #{@join_string}}"
213
+ "{!join from=#{from} to=#{to}}"
173
214
  end
174
215
 
216
+ def eql?(field)
217
+ super && target == field.target && from == field.from && to == field.to
218
+ end
219
+
220
+ alias_method :==, :eql?
175
221
  end
176
222
 
177
223
  class TypeField #:nodoc:
@@ -78,12 +78,11 @@ module Sunspot
78
78
 
79
79
  class Join < Abstract
80
80
  def initialize(name, type, options = {}, &block)
81
- super(name, options, &block)
81
+ super(options[:prefix] ? "#{options[:prefix]}_#{name}" : name, options, &block)
82
82
  unless name.to_s =~ /^\w+$/
83
83
  raise ArgumentError, "Invalid field name #{name}: only letters, numbers, and underscores are allowed."
84
84
  end
85
- @field =
86
- JoinField.new(name, type, options)
85
+ @field = JoinField.new(self.name, type, options)
87
86
  end
88
87
 
89
88
  #
@@ -98,7 +97,6 @@ module Sunspot
98
97
  # into the Solr document for indexing. (noop here for joins)
99
98
  #
100
99
  def populate_document(document, model) #:nodoc:
101
-
102
100
  end
103
101
 
104
102
  #
@@ -8,7 +8,6 @@ module Sunspot
8
8
  # subclasses).
9
9
  #
10
10
  class Indexer #:nodoc:
11
- include RSolr::Char
12
11
 
13
12
  def initialize(connection)
14
13
  @connection = connection
@@ -55,7 +54,7 @@ module Sunspot
55
54
  #
56
55
  def remove_all(clazz = nil)
57
56
  if clazz
58
- @connection.delete_by_query("type:#{escape(clazz.name)}")
57
+ @connection.delete_by_query("type:#{Util.escape(clazz.name)}")
59
58
  else
60
59
  @connection.delete_by_query("*:*")
61
60
  end