sunspot 2.1.1 → 2.2.0

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