eintopf.info@v0.13.16/service/search/search_test.go (about) 1 // Copyright (C) 2022 The Eintopf authors 2 // 3 // This program is free software: you can redistribute it and/or modify 4 // it under the terms of the GNU Affero General Public License as 5 // published by the Free Software Foundation, either version 3 of the 6 // License, or (at your option) any later version. 7 // 8 // This program is distributed in the hope that it will be useful, 9 // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 // GNU Affero General Public License for more details. 12 // 13 // You should have received a copy of the GNU Affero General Public License 14 // along with this program. If not, see <https://www.gnu.org/licenses/>. 15 16 package search_test 17 18 import ( 19 "context" 20 "fmt" 21 "strconv" 22 "sync" 23 "testing" 24 "time" 25 26 "eintopf.info/service/search" 27 "eintopf.info/test" 28 ) 29 30 type doc struct { 31 ID string 32 A string `json:"a"` 33 Int int `json:"b"` 34 C string `json:"c"` 35 D time.Time `json:"d"` 36 E string `json:"e"` 37 Bool bool `json:"bool"` 38 } 39 40 func (d *doc) Identifier() string { 41 return d.ID 42 } 43 44 func (d *doc) Type() string { 45 return "mytype" 46 } 47 48 func (d *doc) QueryText() string { 49 if d.A == "" { 50 return "a" 51 } 52 return d.A 53 } 54 55 func (d *doc) SearchFields() map[string]interface{} { 56 return map[string]interface{}{ 57 "id": d.ID, 58 "a": d.A, 59 "Int": d.Int, 60 "c": d.C, 61 "d": d.D, 62 "e": d.E, 63 "bool": d.Bool, 64 } 65 } 66 67 func TestSearchQuery(t *testing.T) { 68 testSearchTestcases(t, []testcase{ 69 { 70 name: "returns nothing without a match", 71 docs: []doc{{ID: "1", A: "something", Int: 42}}, 72 query: "nothing", 73 wantHits: []doc{}, 74 }, { 75 name: "empty query returns all documents", 76 docs: []doc{{ID: "1", A: "something", Int: 42}, {ID: "2", A: "nothing", Int: 42}}, 77 query: "", 78 wantHits: []doc{{ID: "1", A: "something", Int: 42}, {ID: "2", A: "nothing", Int: 42}}, 79 }, { 80 name: "matches on a word", 81 docs: []doc{{ID: "1", A: "something", Int: 42}}, 82 query: "something", 83 opts: nil, 84 wantHits: []doc{{ID: "1", A: "something", Int: 42}}, 85 }, { 86 name: "matches on part of the text", 87 docs: []doc{{ID: "1", A: "from to", Int: 42}}, 88 query: "from", 89 opts: nil, 90 wantHits: []doc{{ID: "1", A: "from to", Int: 42}}, 91 }, { 92 name: "matches on multiple words in the query", 93 docs: []doc{{ID: "1", A: "from to", Int: 42}}, 94 query: "from to", 95 opts: nil, 96 wantHits: []doc{{ID: "1", A: "from to", Int: 42}}, 97 }, { 98 name: "Fahradtaschen -> Fahradtasche", 99 docs: []doc{{ID: "1", A: "Fahradtaschen", Int: 42}}, 100 query: "fahradtasche", 101 opts: nil, 102 wantHits: []doc{{ID: "1", A: "Fahradtaschen", Int: 42}}, 103 }, { 104 name: "finds multple docs", 105 docs: []doc{ 106 {ID: "1", A: "something", Int: 42}, 107 {ID: "2", A: "something", Int: 43}, 108 }, 109 query: "something", 110 opts: &search.Options{Sort: "id"}, 111 wantHits: []doc{ 112 {ID: "1", A: "something", Int: 42}, 113 {ID: "2", A: "something", Int: 43}, 114 }, 115 }, { 116 name: "doesn't always return everything", 117 docs: []doc{ 118 {ID: "1", A: "something", Int: 42}, 119 {ID: "2", A: "nothing", Int: 43}, 120 }, 121 query: "something", 122 opts: nil, 123 wantHits: []doc{{ID: "1", A: "something", Int: 42}}, 124 }, 125 }) 126 } 127 128 func TestSearchSort(t *testing.T) { 129 testSearchTestcases(t, []testcase{ 130 { 131 name: "sort number field", 132 docs: []doc{{ID: "2", Int: 2}, {ID: "1", Int: 1}}, 133 query: "", 134 opts: &search.Options{Sort: "Int"}, 135 wantHits: []doc{{ID: "1", Int: 1}, {ID: "2", Int: 2}}, 136 }, 137 { 138 name: "sort string field", 139 docs: []doc{{ID: "1", A: "foo"}, {ID: "2", A: "bar"}}, 140 query: "", 141 opts: &search.Options{Sort: "a"}, 142 wantHits: []doc{{ID: "2", A: "bar"}, {ID: "1", A: "foo"}}, 143 }, 144 { 145 name: "sort string field (ignores upper/lower case)", 146 docs: []doc{{ID: "1", A: "Foo"}, {ID: "2", A: "bar"}}, 147 query: "", 148 opts: &search.Options{Sort: "a"}, 149 wantHits: []doc{{ID: "2", A: "bar"}, {ID: "1", A: "Foo"}}, 150 }, 151 { 152 name: "sort time field", 153 docs: []doc{ 154 {ID: "1", D: time.Date(2022, 10, 20, 10, 10, 10, 10, time.UTC)}, 155 {ID: "2", D: time.Date(2022, 10, 10, 10, 10, 10, 10, time.UTC)}, 156 }, 157 query: "", 158 opts: &search.Options{Sort: "d"}, 159 wantHits: []doc{ 160 {ID: "2", D: time.Date(2022, 10, 10, 10, 10, 10, 10, time.UTC)}, 161 {ID: "1", D: time.Date(2022, 10, 20, 10, 10, 10, 10, time.UTC)}, 162 }, 163 }, 164 { 165 name: "sort descending", 166 docs: []doc{{ID: "2", Int: 2}, {ID: "1", Int: 1}}, 167 query: "", 168 opts: &search.Options{Sort: "b", SortDescending: true}, 169 wantHits: []doc{{ID: "2", Int: 2}, {ID: "1", Int: 1}}, 170 }, 171 }) 172 } 173 174 func TestSearchPagination(t *testing.T) { 175 three := uint64(3) 176 testSearchTestcases(t, []testcase{ 177 { 178 name: "NoPagination", 179 docs: []doc{{ID: "1"}, {ID: "2"}, {ID: "3"}}, 180 query: "", 181 opts: &search.Options{Sort: "id", Page: 0, PageSize: 0}, 182 wantHits: []doc{{ID: "1"}, {ID: "2"}, {ID: "3"}}, 183 wantTotal: &three, 184 }, { 185 name: "FirstPage", 186 docs: []doc{{ID: "1"}, {ID: "2"}, {ID: "3"}}, 187 query: "", 188 opts: &search.Options{Sort: "id", Page: 0, PageSize: 2}, 189 wantHits: []doc{{ID: "1"}, {ID: "2"}}, 190 wantTotal: &three, 191 }, { 192 name: "SecondPage", 193 docs: []doc{{ID: "1"}, {ID: "2"}, {ID: "3"}}, 194 query: "", 195 opts: &search.Options{Sort: "id", Page: 1, PageSize: 2}, 196 wantHits: []doc{{ID: "3"}}, 197 wantTotal: &three, 198 }, 199 }) 200 } 201 202 func TestSearchFilterTerms(t *testing.T) { 203 testSearchTestcases(t, []testcase{ 204 { 205 name: "String1", 206 docs: []doc{{ID: "1", C: "foo"}, {ID: "2", C: "bar"}}, 207 opts: &search.Options{Filters: []search.Filter{ 208 &search.TermsFilter{ 209 Field: "c", 210 Terms: []string{"foo"}, 211 }, 212 }}, 213 wantHits: []doc{{ID: "1", C: "foo"}}, 214 }, { 215 name: "String2", 216 docs: []doc{{ID: "1", C: "foo bar"}, {ID: "2", C: "bar"}}, 217 opts: &search.Options{Filters: []search.Filter{ 218 &search.TermsFilter{ 219 Field: "c", 220 Terms: []string{"bar"}, 221 }, 222 }}, 223 wantHits: []doc{{ID: "2", C: "bar"}, {ID: "1", C: "foo bar"}}, 224 }, { 225 name: "UUID", 226 docs: []doc{{ID: "aee609c7-4fea-4a1c-8acf-0799a3bebadd", C: "foo"}, {ID: "39396d6e-fe59-4fea-899a-387d716a8968", C: "bar"}}, 227 opts: &search.Options{Filters: []search.Filter{ 228 &search.TermsFilter{ 229 Field: "id", 230 Terms: []string{"aee609c7-4fea-4a1c-8acf-0799a3bebadd"}, 231 }, 232 }}, 233 wantHits: []doc{{ID: "aee609c7-4fea-4a1c-8acf-0799a3bebadd", C: "foo"}}, 234 }, { 235 name: "Integer", 236 docs: []doc{{ID: "1", Int: 1}, {ID: "2", Int: 2}}, 237 opts: &search.Options{Filters: []search.Filter{ 238 &search.TermsFilter{ 239 Field: "Int", 240 Terms: []string{"1"}, 241 }, 242 }}, 243 wantHits: []doc{}, // can't find int via terms filter 244 }, { 245 name: "MultipleTerms", 246 docs: []doc{ 247 {ID: "1", C: "foo"}, 248 {ID: "2", C: "bar"}, 249 {ID: "3", C: "baz"}, 250 }, 251 opts: &search.Options{Sort: "c", Filters: []search.Filter{ 252 &search.TermsFilter{ 253 Field: "c", 254 Terms: []string{"foo", "baz"}, 255 }, 256 }}, 257 wantHits: []doc{ 258 {ID: "3", C: "baz"}, 259 {ID: "1", C: "foo"}, 260 }, 261 }, 262 }) 263 } 264 265 func TestSearchFilterDateRange(t *testing.T) { 266 testSearchTestcases(t, []testcase{ 267 { 268 name: "DateRangeFilter", 269 docs: []doc{ 270 {ID: "1", D: time.Date(2022, 4, 22, 12, 0, 0, 0, time.UTC)}, 271 {ID: "2", D: time.Date(2022, 4, 22, 14, 0, 0, 0, time.UTC)}, 272 }, 273 opts: &search.Options{Filters: []search.Filter{ 274 &search.DateRangeFilter{ 275 Field: "d", 276 Min: time.Date(2022, 4, 22, 11, 0, 0, 0, time.UTC), 277 Max: time.Date(2022, 4, 22, 13, 0, 0, 0, time.UTC), 278 }, 279 }}, 280 wantHits: []doc{{ID: "1", D: time.Date(2022, 4, 22, 12, 0, 0, 0, time.UTC)}}, 281 }, { 282 name: "DateRangeFilter2", 283 docs: []doc{ 284 {ID: "1", D: now().Add(1 * time.Hour)}, 285 {ID: "2", D: now().Add(-1 * time.Hour)}, 286 }, 287 opts: &search.Options{Filters: []search.Filter{ 288 &search.DateRangeFilter{ 289 Field: "d", 290 Min: now(), 291 }, 292 }}, 293 wantHits: []doc{{ID: "1", D: now().Add(1 * time.Hour)}}, 294 }, 295 }) 296 } 297 298 func TestSearchFilterBool(t *testing.T) { 299 testSearchTestcases(t, []testcase{ 300 { 301 name: "True", 302 docs: []doc{ 303 {ID: "1", Bool: true}, 304 {ID: "2", Bool: false}, 305 {ID: "3", Bool: true}, 306 }, 307 opts: &search.Options{Sort: "id", Filters: []search.Filter{ 308 &search.BoolFilter{ 309 Field: "bool", 310 Value: true, 311 }, 312 }}, 313 wantHits: []doc{ 314 {ID: "1", Bool: true}, 315 {ID: "3", Bool: true}, 316 }, 317 }, { 318 name: "False", 319 docs: []doc{ 320 {ID: "1", Bool: true}, 321 {ID: "2", Bool: false}, 322 {ID: "3", Bool: true}, 323 }, 324 opts: &search.Options{Sort: "id", Filters: []search.Filter{ 325 &search.BoolFilter{ 326 Field: "bool", 327 Value: false, 328 }, 329 }}, 330 wantHits: []doc{ 331 {ID: "2", Bool: false}, 332 }, 333 }, 334 }) 335 } 336 337 func TestSearchFilterNumericRange(t *testing.T) { 338 testSearchTestcases(t, []testcase{ 339 { 340 name: "Min", 341 docs: []doc{ 342 {ID: "1", Int: 1}, 343 {ID: "2", Int: 2}, 344 {ID: "3", Int: 3}, 345 }, 346 opts: &search.Options{Sort: "id", Filters: []search.Filter{ 347 &search.NumericRangeFilter{ 348 Field: "Int", 349 Min: f64ptr(2), 350 }, 351 }}, 352 wantHits: []doc{ 353 {ID: "2", Int: 2}, 354 {ID: "3", Int: 3}, 355 }, 356 }, 357 }) 358 } 359 360 func TestSearchFilterMultiple(t *testing.T) { 361 testSearchTestcases(t, []testcase{ 362 { 363 name: "MultipleFilters", 364 docs: []doc{ 365 {ID: "1", A: "f", C: "foo"}, 366 {ID: "2", A: "b", C: "bar"}, 367 {ID: "3", A: "a", C: "baz"}, 368 }, 369 opts: &search.Options{Filters: []search.Filter{ 370 &search.TermsFilter{ 371 Field: "a", 372 Terms: []string{"f"}, 373 }, 374 &search.TermsFilter{ 375 Field: "c", 376 Terms: []string{"foo"}, 377 }, 378 }}, 379 wantHits: []doc{ 380 {ID: "1", A: "f", C: "foo"}, 381 }, 382 }, 383 }) 384 } 385 386 func TestSearchAggregation(t *testing.T) { 387 time1 := time.Date(2019, 1, 12, 20, 0, 0, 0, time.UTC) 388 time2 := time.Date(2019, 2, 12, 20, 0, 0, 0, time.UTC) 389 testSearchTestcases(t, []testcase{ 390 { 391 name: "Terms", 392 docs: []doc{ 393 {ID: "1", C: "foo"}, 394 {ID: "2", C: "bar"}, 395 {ID: "3", C: "bar"}, 396 }, 397 opts: &search.Options{Aggregations: map[string]search.Aggregation{ 398 "foo": { 399 Field: "c", 400 Type: search.TermsAggregation, 401 }, 402 }}, 403 wantBuckets: map[string]search.Bucket{ 404 "foo": search.TermsBucket([]search.Term{ 405 {Term: "bar", Count: 2}, 406 {Term: "foo", Count: 1}, 407 }), 408 }, 409 }, { 410 name: "DateRange", 411 docs: []doc{{ID: "1", D: time1}, {ID: "2", D: time2}}, 412 opts: &search.Options{Aggregations: map[string]search.Aggregation{ 413 "foo": { 414 Field: "d", 415 Type: search.DateRangeAggregation, 416 }, 417 }}, 418 wantBuckets: map[string]search.Bucket{ 419 "foo": search.DateRangeBucket{ 420 Min: time1, 421 Max: time2, 422 }, 423 }, 424 }, { 425 name: "WithTermsFilter", 426 docs: []doc{{ID: "1", A: "f", C: "foo"}, {ID: "2", A: "b", C: "bar"}}, 427 opts: &search.Options{Aggregations: map[string]search.Aggregation{ 428 "foo": { 429 Field: "c", 430 Type: search.TermsAggregation, 431 Filters: []search.Filter{ 432 &search.TermsFilter{ 433 Field: "a", 434 Terms: []string{"f"}, 435 }, 436 }, 437 }, 438 }}, 439 wantBuckets: map[string]search.Bucket{ 440 "foo": search.TermsBucket([]search.Term{ 441 {Term: "foo", Count: 1}, 442 }), 443 }, 444 }, 445 }) 446 } 447 448 type testcase struct { 449 name string 450 docs []doc 451 query string 452 opts *search.Options 453 wantHits []doc 454 wantBuckets map[string]search.Bucket 455 wantTotal *uint64 456 } 457 458 func testSearchTestcases(t *testing.T, testcases []testcase) { 459 t.Helper() 460 for _, tc := range testcases { 461 t.Run(tc.name, func(tt *testing.T) { 462 index, cleanupIndex := test.CreateBleveTestIndex(tc.name) 463 tt.Cleanup(cleanupIndex) 464 s, err := search.NewService(index, time.Second, 1, 1, time.UTC) 465 if err != nil { 466 tt.Errorf("search.NewService unexpectedly failed: %s", err) 467 } 468 defer s.Stop() 469 470 for _, doc := range tc.docs { 471 err = s.Index(&doc) 472 if err != nil { 473 tt.Errorf("Index() unexpectedly failed: %s", err) 474 } 475 } 476 477 if tc.opts == nil { 478 tc.opts = &search.Options{} 479 } 480 tc.opts.Query = tc.query 481 result, err := s.Search(context.Background(), tc.opts) 482 if err != nil { 483 tt.Errorf("Search(%s, opts) unexpectedly failed: %s", tc.query, err) 484 } 485 486 if tc.wantHits != nil { 487 hits := []doc{} 488 for _, rawHit := range result.Hits { 489 var hit doc 490 err = rawHit.Unmarshal(&hit) 491 if err != nil { 492 tt.Errorf("hit.Unmarshal unexpectedly failed: %s", err) 493 } 494 hits = append(hits, hit) 495 } 496 if fmt.Sprint(hits) != fmt.Sprint(tc.wantHits) { 497 tt.Errorf("hits don't match:\nwant: %v\ngot: %v", tc.wantHits, hits) 498 } 499 } 500 501 if tc.wantBuckets != nil { 502 if fmt.Sprint(result.Buckets) != fmt.Sprint(tc.wantBuckets) { 503 tt.Errorf("buckets don't match: \nwant: %v\ngot: %v", tc.wantBuckets, result.Buckets) 504 } 505 } 506 507 if tc.wantTotal != nil { 508 if *tc.wantTotal != result.Total { 509 tt.Errorf("total don't match: want %d, got %d", &tc.wantTotal, result.Total) 510 } 511 } 512 }) 513 } 514 } 515 516 func TestIndexConcurrently(t *testing.T) { 517 index, cleanupIndex := test.CreateBleveTestIndex("TestIndexConcurrently") 518 t.Cleanup(cleanupIndex) 519 s, err := search.NewService(index, time.Second, 1, 1, time.UTC) 520 if err != nil { 521 t.Fatalf("search.NewService() failed unexpectedly: %s", err) 522 } 523 defer s.Stop() 524 525 wg := sync.WaitGroup{} 526 527 for _, d := range makeDocs(10) { 528 dd := &d 529 wg.Add(1) 530 go func(i search.Indexable) { 531 s.Index(i) 532 wg.Done() 533 }(dd) 534 } 535 536 wg.Wait() 537 } 538 539 func TestSearchServiceWithExistingIndex(t *testing.T) { 540 index, cleanupIndex := test.CreateBleveTestIndex("TestSearchServiceWithExistingIndex") 541 t.Cleanup(cleanupIndex) 542 543 s, err := search.NewService(index, time.Second, 1, 1, time.UTC) 544 if err != nil { 545 t.Fatalf("search.NewService() failed unexpectedly: %s", err) 546 } 547 s.Stop() 548 549 s, err = search.NewService(index, time.Second, 1, 1, time.UTC) 550 if err != nil { 551 t.Fatalf("search.NewService() failed with existing index: %s", err) 552 } 553 s.Stop() 554 } 555 556 func TestSearchCancel(t *testing.T) { 557 index, cleanupIndex := test.CreateBleveTestIndex("TestSearchCancel") 558 t.Cleanup(cleanupIndex) 559 560 s, err := search.NewService(index, time.Second*10, 1, 1, time.UTC) 561 if err != nil { 562 t.Fatalf("search.NewService() failed unexpectedly: %s", err) 563 } 564 t.Cleanup(s.Stop) 565 // Add documents to the index, such that a search is not instant. 566 for i := 0; i < 100; i++ { 567 s.Index(&doc{ID: fmt.Sprintf("%d", i), A: "f", C: "foo"}) 568 } 569 570 ctx, cancel := context.WithCancel(context.Background()) 571 go func() { cancel() }() 572 _, err = s.Search(ctx, nil) 573 if err == nil || err.Error() != "context canceled" { 574 t.Errorf("err should be 'context canceled', got: %s", err) 575 } 576 } 577 578 func TestSearchCancelTimeout(t *testing.T) { 579 index, cleanupIndex := test.CreateBleveTestIndex("TestSearchCancelTimeout") 580 t.Cleanup(cleanupIndex) 581 582 s, err := search.NewService(index, time.Nanosecond, 1, 1, time.UTC) 583 if err != nil { 584 t.Fatalf("search.NewService() failed unexpectedly: %s", err) 585 } 586 t.Cleanup(s.Stop) 587 588 _, err = s.Search(context.Background(), nil) 589 if err == nil || err.Error() != "context deadline exceeded" { 590 t.Errorf("err should be 'context deadline exceeded', got: %s", err) 591 } 592 } 593 594 func makeDocs(count int) []doc { 595 docs := make([]doc, 0, count) 596 for i := 0; i < count; i++ { 597 docs = append(docs, doc{ 598 A: "djasd", 599 D: time.Unix(0, 0), 600 }) 601 } 602 return docs 603 } 604 605 func makeAggregations(count int) map[string]search.Aggregation { 606 aggregations := make(map[string]search.Aggregation) 607 for i := 0; i < count; i++ { 608 aggregations[strconv.Itoa(i)+"_terms"] = search.Aggregation{ 609 Type: search.TermsAggregation, 610 Field: "a", 611 } 612 aggregations[strconv.Itoa(i)+"_daterange"] = search.Aggregation{ 613 Type: search.DateRangeAggregation, 614 Field: "d", 615 } 616 } 617 return aggregations 618 } 619 620 func BenchmarkSearch(b *testing.B) { 621 benchcases := []struct { 622 name string 623 docs []doc 624 query string 625 opts *search.Options 626 }{ 627 { 628 name: "Normal", 629 docs: makeDocs(1000), 630 query: "", 631 }, { 632 name: "AggregationSmall", 633 docs: makeDocs(100), 634 query: "", 635 opts: &search.Options{ 636 Aggregations: makeAggregations(2), 637 }, 638 }, { 639 name: "AggregationBig", 640 docs: makeDocs(100), 641 query: "", 642 opts: &search.Options{ 643 Aggregations: makeAggregations(100), 644 }, 645 }, 646 } 647 648 for _, bc := range benchcases { 649 b.Run(bc.name, func(bb *testing.B) { 650 index, cleanupIndex := test.CreateBleveTestIndex(bc.name) 651 bb.Cleanup(cleanupIndex) 652 653 s, err := search.NewService(index, time.Second, 1, 1, time.UTC) 654 if err != nil { 655 bb.Fatalf("search.NewService() failed unexpectedly: %s", err) 656 } 657 defer s.Stop() 658 659 for _, doc := range bc.docs { 660 err = s.Index(&doc) 661 if err != nil { 662 bb.Fatalf("Index() unexpectedly failed: %s", err) 663 } 664 } 665 666 bb.ResetTimer() 667 for n := 0; n < bb.N; n++ { 668 bc.opts.Query = bc.query 669 _, err := s.Search(context.Background(), bc.opts) 670 if err != nil { 671 bb.Fatalf("Search(%s, opts) unexpectedly failed: %s", "foo", err) 672 } 673 } 674 }) 675 } 676 } 677 678 func now() time.Time { 679 n := time.Now() 680 return time.Date(n.Year(), n.Month(), n.Hour(), n.Minute(), 0, 0, 0, time.UTC) 681 } 682 683 func f64ptr(f float64) *float64 { 684 return &f 685 }