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
@@ -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