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
@@ -1,3 +1,3 @@
1
1
  module Sunspot
2
- VERSION = '2.1.1'
2
+ VERSION = '2.2.0'
3
3
  end
@@ -188,7 +188,8 @@ shared_examples_for 'fulltext query' do
188
188
  search Photo do
189
189
  keywords 'great pizza'
190
190
  end
191
- connection.should have_last_search_with(:qf => 'caption_text^1.5')
191
+ # Hashes in 1.8 aren't ordered
192
+ connection.searches.last[:qf].split(" ").sort.join(" ").should eq 'caption_text^1.5 description_text'
192
193
  end
193
194
 
194
195
  it 'sets default boost with fields specified in options' do
@@ -310,4 +311,152 @@ shared_examples_for 'fulltext query' do
310
311
  end
311
312
  end.should raise_error(Sunspot::UnrecognizedFieldError)
312
313
  end
314
+
315
+ describe 'connective examples' do
316
+ it 'creates a disjunction between two subqueries' do
317
+ search Post do
318
+ any do
319
+ fulltext 'keywords1', :fields => :title
320
+ fulltext 'keyword2', :fields => :body
321
+ end
322
+ end
323
+
324
+ connection.searches.last[:q].should eq "(_query_:\"{!edismax qf='title_text'}keywords1\" OR _query_:\"{!edismax qf='body_textsv'}keyword2\")"
325
+ end
326
+
327
+ it 'creates a conjunction inside of a disjunction' do
328
+ search do
329
+ any do
330
+ fulltext 'keywords1', :fields => :body
331
+
332
+ all do
333
+ fulltext 'keyword2', :fields => :body
334
+ fulltext 'keyword3', :fields => :body
335
+ end
336
+ end
337
+ end
338
+
339
+ connection.searches.last[:q].should eq "(_query_:\"{!edismax qf='body_textsv'}keywords1\" OR (_query_:\"{!edismax qf='body_textsv'}keyword2\" AND _query_:\"{!edismax qf='body_textsv'}keyword3\"))"
340
+ end
341
+
342
+ it 'does nothing special if #all/#any called from the top level or called multiple times' do
343
+ search Post do
344
+ all do
345
+ fulltext 'keywords1', :fields => :title
346
+ fulltext 'keyword2', :fields => :body
347
+ end
348
+ end
349
+
350
+ connection.searches.last[:q].should eq "(_query_:\"{!edismax qf='title_text'}keywords1\" AND _query_:\"{!edismax qf='body_textsv'}keyword2\")"
351
+ end
352
+
353
+ it 'does nothing special if #all/#any are mixed and called multiple times' do
354
+ search Post do
355
+ all do
356
+ any do
357
+ all do
358
+ fulltext 'keywords1', :fields => :title
359
+ fulltext 'keyword2', :fields => :body
360
+ end
361
+ end
362
+ end
363
+ end
364
+
365
+ connection.searches.last[:q].should eq "(_query_:\"{!edismax qf='title_text'}keywords1\" AND _query_:\"{!edismax qf='body_textsv'}keyword2\")"
366
+
367
+ search Post do
368
+ any do
369
+ all do
370
+ any do
371
+ fulltext 'keywords1', :fields => :title
372
+ fulltext 'keyword2', :fields => :body
373
+ end
374
+ end
375
+ end
376
+ end
377
+
378
+ connection.searches.last[:q].should eq "(_query_:\"{!edismax qf='title_text'}keywords1\" OR _query_:\"{!edismax qf='body_textsv'}keyword2\")"
379
+ end
380
+
381
+ it "does not add empty parentheses" do
382
+ search Post do
383
+ any do
384
+ all do
385
+ end
386
+
387
+ any do
388
+ fulltext 'keywords1', :fields => :title
389
+ all do
390
+ end
391
+ end
392
+ end
393
+ end
394
+
395
+ connection.searches.last[:q].should eq "_query_:\"{!edismax qf='title_text'}keywords1\""
396
+ end
397
+ end
398
+
399
+ describe "joins" do
400
+ it "should search by join" do
401
+ srch = search PhotoContainer do
402
+ any do
403
+ fulltext 'keyword1', :fields => :caption
404
+ fulltext 'keyword2', :fields => :description
405
+ end
406
+ end
407
+
408
+ obj_id = find_ob_id(srch)
409
+ q_name = "qPhoto#{obj_id}"
410
+ fq_name = "f#{q_name}"
411
+
412
+ connection.searches.last[:q].should eq "(_query_:\"{!join from=photo_container_id_i to=id_i v=$#{q_name} fq=$#{fq_name}}\" OR _query_:\"{!edismax qf='description_text^1.2'}keyword2\")"
413
+ connection.searches.last[q_name].should eq "_query_:\"{!edismax qf='caption_text'}keyword1\""
414
+ connection.searches.last[fq_name].should eq "type:Photo"
415
+ end
416
+
417
+ it "should be able to resolve name conflicts with the :prefix option" do
418
+ srch = search PhotoContainer do
419
+ any do
420
+ fulltext 'keyword1', :fields => :description
421
+ fulltext 'keyword2', :fields => :photo_description
422
+ end
423
+ end
424
+
425
+ obj_id = find_ob_id(srch)
426
+ q_name = "qPhoto#{obj_id}"
427
+ fq_name = "f#{q_name}"
428
+
429
+ connection.searches.last[:q].should eq "(_query_:\"{!edismax qf='description_text^1.2'}keyword1\" OR _query_:\"{!join from=photo_container_id_i to=id_i v=$#{q_name} fq=$#{fq_name}}\")"
430
+ connection.searches.last[q_name].should eq "_query_:\"{!edismax qf='description_text'}keyword2\""
431
+ connection.searches.last[fq_name].should eq "type:Photo"
432
+ end
433
+
434
+ it "should recognize fields when adding from DSL, e.g. when calling boost_fields" do
435
+ srch = search PhotoContainer do
436
+ any do
437
+ fulltext 'keyword1', :fields => [:photo_description, :description] do
438
+ boost_fields(:photo_description => 1.3, :description => 1.5)
439
+ end
440
+ end
441
+ end
442
+
443
+ obj_id = find_ob_id(srch)
444
+ q_name = "qPhoto#{obj_id}"
445
+ fq_name = "f#{q_name}"
446
+
447
+ connection.searches.last[:q].should eq "(_query_:\"{!edismax qf='description_text^1.5'}keyword1\" OR _query_:\"{!join from=photo_container_id_i to=id_i v=$#{q_name} fq=$#{fq_name}}\")"
448
+ connection.searches.last[q_name].should eq "_query_:\"{!edismax qf='description_text^1.3'}keyword1\""
449
+ connection.searches.last[fq_name].should eq "type:Photo"
450
+ end
451
+
452
+ private
453
+
454
+ def find_ob_id(search)
455
+ search.query.
456
+ instance_variable_get("@components").find { |c| c.is_a?(Sunspot::Query::Conjunction) }.
457
+ instance_variable_get("@components").find { |c| c.is_a?(Sunspot::Query::Disjunction) }.
458
+ instance_variable_get("@components").find { |c| c.is_a?(Sunspot::Query::Join) }.
459
+ object_id
460
+ end
461
+ end
313
462
  end
@@ -41,12 +41,8 @@ shared_examples_for 'geohash query' do
41
41
  fulltext 'pizza', :fields => :title
42
42
  with(:coordinates).near(40.7, -73.5)
43
43
  end
44
- expected =
45
- "{!edismax fl='* score' qf='title_text'}pizza (#{build_geo_query})"
46
- connection.should have_last_search_including(
47
- :q,
48
- %Q(_query_:"{!edismax qf='title_text'}pizza" (#{build_geo_query}))
49
- )
44
+ expected = %Q((_query_:"{!edismax qf='title_text'}pizza" AND (#{build_geo_query})))
45
+ connection.should have_last_search_including(:q, expected)
50
46
  end
51
47
 
52
48
  private
@@ -6,7 +6,7 @@ describe 'join' do
6
6
  with(:caption, 'blah')
7
7
  end
8
8
  connection.should have_last_search_including(
9
- :fq, "{!join from=photo_container_id to=id}caption_s:blah")
9
+ :fq, "{!join from=photo_container_id_i to=id_i}caption_s:blah")
10
10
  end
11
11
 
12
12
  it 'should greater_than search by join' do
@@ -14,6 +14,6 @@ describe 'join' do
14
14
  with(:photo_rating).greater_than(3)
15
15
  end
16
16
  connection.should have_last_search_including(
17
- :fq, "{!join from=photo_container_id to=id}average_rating_ft:{3\\.0 TO *}")
17
+ :fq, "{!join from=photo_container_id_i to=id_i}average_rating_ft:{3\\.0 TO *}")
18
18
  end
19
- end
19
+ end
@@ -46,6 +46,20 @@ shared_examples_for 'sortable query' do
46
46
  connection.should have_last_search_with(:rows => 15, :start => 30)
47
47
  end
48
48
 
49
+ it 'paginates with initial cursor' do
50
+ search do
51
+ paginate :cursor => '*', :per_page => 15
52
+ end
53
+ connection.should have_last_search_with(:rows => 15, :cursorMark => '*')
54
+ end
55
+
56
+ it 'paginates with given cursor' do
57
+ search do
58
+ paginate :cursor => 'AoIIP4AAACxQcm9maWxlIDEwMTk='
59
+ end
60
+ connection.should have_last_search_with(:cursorMark => 'AoIIP4AAACxQcm9maWxlIDEwMTk=')
61
+ end
62
+
49
63
  it 'orders by a single field' do
50
64
  search do
51
65
  order_by :average_rating, :desc
@@ -0,0 +1,20 @@
1
+ require File.expand_path('spec_helper', File.dirname(__FILE__))
2
+
3
+ shared_examples_for 'spellcheck query' do
4
+
5
+ it 'sends spellcheck parameters to solr' do
6
+ search do
7
+ spellcheck
8
+ end
9
+ connection.should have_last_search_including(:spellcheck, true)
10
+ end
11
+
12
+
13
+ it "sends additional spellcheck parameters with camel casing" do
14
+ search do
15
+ spellcheck :only_more_popular => true, :count => 5
16
+ end
17
+ connection.should have_last_search_including('spellcheck.onlyMorePopular', true)
18
+ connection.should have_last_search_including('spellcheck.count', 5)
19
+ end
20
+ end
@@ -13,6 +13,7 @@ describe 'standard query', :type => :query do
13
13
  it_should_behave_like "geohash query"
14
14
  it_should_behave_like "spatial query"
15
15
  it_should_behave_like "stats query"
16
+ it_should_behave_like "spellcheck query"
16
17
 
17
18
  it 'adds a no-op query to :q parameter when no :q provided' do
18
19
  session.search Post do
@@ -0,0 +1,35 @@
1
+ require File.expand_path('spec_helper', File.dirname(__FILE__))
2
+
3
+ describe "CursorPaginatedCollection" do
4
+ subject { Sunspot::Search::CursorPaginatedCollection.new [], 10, 20, '*', 'AoIIP4AAACxQcm9maWxlIDEwMTk=' }
5
+
6
+ it { subject.should be_an(Array) }
7
+
8
+ describe "#send" do
9
+ it 'should allow send' do
10
+ expect { subject.send(:current_cursor) }.to_not raise_error
11
+ end
12
+ end
13
+
14
+ describe "#respond_to?" do
15
+ it 'should return true for current_cursor' do
16
+ subject.respond_to?(:current_cursor).should be_true
17
+ end
18
+ end
19
+
20
+ context "behaves like a WillPaginate::Collection" do
21
+ it { subject.total_entries.should eql(20) }
22
+ it { subject.total_pages.should eql(2) }
23
+ it { subject.current_cursor.should eql('*') }
24
+ it { subject.per_page.should eql(10) }
25
+ it { subject.next_page_cursor.should eql('AoIIP4AAACxQcm9maWxlIDEwMTk=') }
26
+ end
27
+
28
+ context "behaves like Kaminari" do
29
+ it { subject.total_count.should eql(20) }
30
+ it { subject.num_pages.should eql(2) }
31
+ it { subject.limit_value.should eql(10) }
32
+ it { subject.first_page?.should be_true }
33
+ it { subject.last_page?.should be_true }
34
+ end
35
+ end
@@ -23,6 +23,7 @@ describe "PaginatedCollection" do
23
23
  it { subject.current_page.should eql(1) }
24
24
  it { subject.per_page.should eql(10) }
25
25
  it { subject.previous_page.should be_nil }
26
+ it { subject.prev_page.should be_nil }
26
27
  it { subject.next_page.should eql(2) }
27
28
  it { subject.out_of_bounds?.should_not be_true }
28
29
  it { subject.offset.should eql(0) }
@@ -35,6 +35,26 @@ shared_examples_for 'all sessions' do
35
35
  end
36
36
  end
37
37
 
38
+ context '#commit(bool)' do
39
+ it 'should soft-commit if bool=true' do
40
+ @session.commit(true)
41
+ connection.should have(1).commits
42
+ connection.should have(1).soft_commits
43
+ end
44
+
45
+ it 'should hard-commit if bool=false' do
46
+ @session.commit(false)
47
+ connection.should have(1).commits
48
+ connection.should have(0).soft_commits
49
+ end
50
+
51
+ it 'should hard-commit if bool is not specified' do
52
+ @session.commit
53
+ connection.should have(1).commits
54
+ connection.should have(0).soft_commits
55
+ end
56
+ end
57
+
38
58
  context '#optimize()' do
39
59
  before :each do
40
60
  @session.optimize
@@ -202,17 +222,31 @@ describe 'Session' do
202
222
  connection.should have(0).commits
203
223
  end
204
224
 
205
- it 'should commit when commit_if_dirty called on dirty session' do
225
+ it 'should hard commit when commit_if_dirty called on dirty session' do
206
226
  @session.index(Post.new)
207
227
  @session.commit_if_dirty
208
228
  connection.should have(1).commits
209
229
  end
210
230
 
211
- it 'should commit when commit_if_delete_dirty called on delete_dirty session' do
231
+ it 'should soft commit when commit_if_dirty called on dirty session' do
232
+ @session.index(Post.new)
233
+ @session.commit_if_dirty(true)
234
+ connection.should have(1).commits
235
+ connection.should have(1).soft_commits
236
+ end
237
+
238
+ it 'should hard commit when commit_if_delete_dirty called on delete_dirty session' do
212
239
  @session.remove(Post.new)
213
240
  @session.commit_if_delete_dirty
214
241
  connection.should have(1).commits
215
242
  end
243
+
244
+ it 'should soft commit when commit_if_delete_dirty called on delete_dirty session' do
245
+ @session.remove(Post.new)
246
+ @session.commit_if_delete_dirty(true)
247
+ connection.should have(1).commits
248
+ connection.should have(1).soft_commits
249
+ end
216
250
  end
217
251
 
218
252
  context 'session proxy' do
@@ -0,0 +1,74 @@
1
+ require File.expand_path('../spec_helper', File.dirname(__FILE__))
2
+ include SearchHelper
3
+
4
+ describe 'spellcheck' do
5
+ before :each do
6
+ Sunspot.remove_all
7
+
8
+ @posts = [
9
+ Post.new(:title => 'Clojure Developer'),
10
+ Post.new(:title => 'Conjure Flow'),
11
+ Post.new(:title => 'Clojure Analyst'),
12
+ Post.new(:title => 'C++ Developer'),
13
+ Post.new(:title => 'C++ Developing')
14
+ ]
15
+
16
+ Sunspot.index!(*@posts)
17
+ Sunspot.commit
18
+ end
19
+
20
+ it 'has no spellchecking by default' do
21
+ search = Sunspot.search(Post) do
22
+ keywords 'Closure'
23
+ end
24
+ search.spellcheck_suggestions.should == {}
25
+ end
26
+
27
+ it 'returns the list of suggestions' do
28
+ search = Sunspot.search(Post) do
29
+ keywords 'Closure'
30
+ spellcheck :count => 3
31
+ end
32
+ search.spellcheck_suggestions['closure']['suggestion'].should == [
33
+ {'word'=>'clojure', 'freq'=>2}, {'word'=>'conjure', 'freq'=>1}
34
+ ]
35
+ end
36
+
37
+ it 'returns suggestion with highest frequency' do
38
+ search = Sunspot.search(Post) do
39
+ keywords 'Closure'
40
+ spellcheck :count => 3
41
+ end
42
+ search.spellcheck_suggestion_for('closure').should == 'clojure'
43
+ end
44
+
45
+ it 'returns suggestion without collation when only more popular is true' do
46
+ search = Sunspot.search(Post) do
47
+ keywords 'Closure'
48
+ spellcheck :count => 3, :only_more_popular => true, :collate => false
49
+ end
50
+
51
+ search.spellcheck_suggestion_for('closure').should == 'clojure'
52
+ end
53
+
54
+ context 'spellcheck collation' do
55
+ it 'replaces terms that are not in the index if terms are provided' do
56
+
57
+ search = Sunspot.search(Post) do
58
+ keywords 'lojure developing'
59
+ spellcheck :count => 3, :only_more_popular => true
60
+ end
61
+ search.spellcheck_collation('lojure', 'developing').should == 'clojure developing'
62
+ end
63
+
64
+ it 'returns Solr collation if terms are not provided' do
65
+
66
+ search = Sunspot.search(Post) do
67
+ keywords 'lojure developing'
68
+ spellcheck :count => 3, :only_more_popular => true
69
+ end
70
+ search.spellcheck_collation.should == 'clojure developer'
71
+ end
72
+ end
73
+
74
+ end
@@ -22,7 +22,7 @@ module Mock
22
22
  end
23
23
 
24
24
  class Connection
25
- attr_reader :adds, :commits, :optims, :searches, :message, :opts, :deletes_by_query
25
+ attr_reader :adds, :commits, :soft_commits, :optims, :searches, :message, :opts, :deletes_by_query
26
26
  attr_accessor :response
27
27
  attr_writer :expected_handler
28
28
  undef_method :select # annoyingly defined on Object
@@ -30,7 +30,7 @@ module Mock
30
30
  def initialize(opts = {})
31
31
  @opts = opts
32
32
  @message = OpenStruct.new
33
- @adds, @deletes, @deletes_by_query, @commits, @optims, @searches = Array.new(6) { [] }
33
+ @adds, @deletes, @deletes_by_query, @commits, @soft_commits, @optims, @searches = Array.new(7) { [] }
34
34
  @expected_handler = :select
35
35
  end
36
36
 
@@ -46,8 +46,10 @@ module Mock
46
46
  @deletes_by_query << query
47
47
  end
48
48
 
49
- def commit
49
+ def commit(opts = {})
50
+ commit_attrs = opts.delete(:commit_attributes) || {}
50
51
  @commits << Time.now
52
+ @soft_commits << Time.now if commit_attrs[:softCommit]
51
53
  end
52
54
 
53
55
  def optimize