1use crate::error::{Result, SearchError};
16use rustlavel_core::Json;
17use std::collections::BTreeMap;
18
19#[derive(Debug, Clone, PartialEq)]
21pub struct SearchResults {
22 pub took: u64,
24 pub timed_out: bool,
26 pub shards: Shards,
27 pub total: TotalHits,
28 pub max_score: Option<f64>,
31 pub hits: Vec<Hit>,
32 pub aggregations: Aggregations,
33}
34
35impl SearchResults {
36 pub fn parse(body: &Json) -> Result<SearchResults> {
37 let hits = body.get("hits").ok_or_else(|| SearchError::Malformed {
38 context: "_search".to_string(),
39 message: "the reply has no `hits`".to_string(),
40 })?;
41
42 Ok(SearchResults {
43 took: u64_at(body, "took"),
44 timed_out: body.get("timed_out").and_then(Json::as_bool).unwrap_or(false),
45 shards: Shards::parse(body.get("_shards")),
46 total: TotalHits::parse(hits.get("total")),
47 max_score: hits.get("max_score").and_then(Json::as_f64),
48 hits: hits
49 .get("hits")
50 .and_then(Json::as_array)
51 .map(|items| items.iter().map(Hit::parse).collect())
52 .unwrap_or_default(),
53 aggregations: Aggregations::parse(body.get("aggregations")),
54 })
55 }
56
57 pub fn len(&self) -> usize {
59 self.hits.len()
60 }
61
62 pub fn is_empty(&self) -> bool {
63 self.hits.is_empty()
64 }
65
66 pub fn is_partial(&self) -> bool {
73 self.timed_out || self.shards.failed > 0
74 }
75
76 pub fn sources(&self) -> impl Iterator<Item = &Json> {
78 self.hits.iter().map(|hit| &hit.source)
79 }
80
81 pub fn ids(&self) -> Vec<&str> {
83 self.hits.iter().map(|hit| hit.id.as_str()).collect()
84 }
85
86 pub fn first(&self) -> Option<&Hit> {
88 self.hits.first()
89 }
90}
91
92#[derive(Debug, Clone, Default, PartialEq)]
94pub struct Shards {
95 pub total: u32,
96 pub successful: u32,
97 pub skipped: u32,
98 pub failed: u32,
99 pub failures: Vec<String>,
101}
102
103impl Shards {
104 fn parse(body: Option<&Json>) -> Shards {
105 let Some(body) = body else {
106 return Shards::default();
107 };
108
109 Shards {
110 total: u32_at(body, "total"),
111 successful: u32_at(body, "successful"),
112 skipped: u32_at(body, "skipped"),
113 failed: u32_at(body, "failed"),
114 failures: body
115 .get("failures")
116 .and_then(Json::as_array)
117 .map(|items| {
118 items
119 .iter()
120 .map(|failure| {
121 failure
122 .get("reason.reason")
123 .or_else(|| failure.get("reason"))
124 .and_then(Json::as_str)
125 .unwrap_or("no reason given")
126 .to_string()
127 })
128 .collect()
129 })
130 .unwrap_or_default(),
131 }
132 }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum TotalRelation {
138 Exact,
140 AtLeast,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub struct TotalHits {
153 pub value: u64,
154 pub relation: TotalRelation,
155}
156
157impl TotalHits {
158 fn parse(body: Option<&Json>) -> TotalHits {
159 let Some(body) = body else {
160 return TotalHits { value: 0, relation: TotalRelation::Exact };
161 };
162
163 if let Some(value) = body.as_i64() {
166 return TotalHits { value: value.max(0) as u64, relation: TotalRelation::Exact };
167 }
168
169 TotalHits {
170 value: body.get("value").and_then(Json::as_i64).unwrap_or(0).max(0) as u64,
171 relation: match body.get("relation").and_then(Json::as_str) {
172 Some("gte") => TotalRelation::AtLeast,
173 _ => TotalRelation::Exact,
174 },
175 }
176 }
177
178 pub fn is_exact(&self) -> bool {
180 self.relation == TotalRelation::Exact
181 }
182
183 pub fn exact(&self) -> Option<u64> {
188 self.is_exact().then_some(self.value)
189 }
190}
191
192impl std::fmt::Display for TotalHits {
193 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194 match self.relation {
195 TotalRelation::Exact => write!(f, "{}", self.value),
196 TotalRelation::AtLeast => write!(f, "at least {}", self.value),
197 }
198 }
199}
200
201#[derive(Debug, Clone, PartialEq)]
203pub struct Hit {
204 pub index: String,
205 pub id: String,
206 pub score: Option<f64>,
208 pub source: Json,
210 pub sort: Vec<Json>,
212}
213
214impl Hit {
215 fn parse(body: &Json) -> Hit {
216 Hit {
217 index: string_at(body, "_index"),
218 id: string_at(body, "_id"),
219 score: body.get("_score").and_then(Json::as_f64),
220 source: body.get("_source").cloned().unwrap_or(Json::Null),
221 sort: body.get("sort").and_then(Json::as_array).map(<[Json]>::to_vec).unwrap_or_default(),
222 }
223 }
224
225 pub fn field(&self, path: &str) -> Option<&Json> {
227 self.source.get(path)
228 }
229
230 pub fn string(&self, path: &str) -> Option<&str> {
232 self.source.get(path).and_then(Json::as_str)
233 }
234}
235
236#[derive(Debug, Clone, Default, PartialEq)]
238pub struct Aggregations(BTreeMap<String, Json>);
239
240impl Aggregations {
241 fn parse(body: Option<&Json>) -> Aggregations {
242 Aggregations(
243 body.and_then(Json::as_object).cloned().unwrap_or_default(),
244 )
245 }
246
247 pub fn is_empty(&self) -> bool {
248 self.0.is_empty()
249 }
250
251 pub fn names(&self) -> Vec<&str> {
252 self.0.keys().map(String::as_str).collect()
253 }
254
255 pub fn raw(&self, name: &str) -> Option<&Json> {
258 self.0.get(name)
259 }
260
261 pub fn terms(&self, name: &str) -> Option<TermsResult> {
263 let body = self.0.get(name)?;
264 let buckets = body.get("buckets")?.as_array()?;
265
266 Some(TermsResult {
267 doc_count_error_upper_bound: u64_at(body, "doc_count_error_upper_bound"),
268 sum_other_doc_count: u64_at(body, "sum_other_doc_count"),
269 buckets: buckets.iter().map(Bucket::parse).collect(),
270 })
271 }
272
273 pub fn stats(&self, name: &str) -> Option<StatsResult> {
275 let body = self.0.get(name)?;
276
277 Some(StatsResult {
278 count: u64_at(body, "count"),
279 min: body.get("min").and_then(Json::as_f64),
283 max: body.get("max").and_then(Json::as_f64),
284 avg: body.get("avg").and_then(Json::as_f64),
285 sum: body.get("sum").and_then(Json::as_f64).unwrap_or(0.0),
286 })
287 }
288
289 pub fn value(&self, name: &str) -> Option<f64> {
292 self.0.get(name)?.get("value")?.as_f64()
293 }
294}
295
296#[derive(Debug, Clone, PartialEq)]
298pub struct TermsResult {
299 pub buckets: Vec<Bucket>,
300 pub doc_count_error_upper_bound: u64,
302 pub sum_other_doc_count: u64,
304}
305
306impl TermsResult {
307 pub fn is_approximate(&self) -> bool {
314 self.sum_other_doc_count > 0 || self.doc_count_error_upper_bound > 0
315 }
316
317 pub fn get(&self, key: &str) -> Option<&Bucket> {
319 self.buckets.iter().find(|bucket| bucket.key_string() == key)
320 }
321
322 pub fn count(&self, key: &str) -> u64 {
326 self.get(key).map(|bucket| bucket.doc_count).unwrap_or(0)
327 }
328}
329
330#[derive(Debug, Clone, PartialEq)]
332pub struct Bucket {
333 pub key: Json,
336 pub doc_count: u64,
337 pub aggregations: Aggregations,
339}
340
341impl Bucket {
342 fn parse(body: &Json) -> Bucket {
343 Bucket {
344 key: body.get("key").cloned().unwrap_or(Json::Null),
345 doc_count: u64_at(body, "doc_count"),
346 aggregations: Aggregations::parse(Some(body)),
351 }
352 }
353
354 pub fn key_string(&self) -> String {
359 if let Some(Json::String(text)) = self.aggregations.raw("key_as_string") {
360 return text.clone();
361 }
362 match &self.key {
363 Json::String(text) => text.clone(),
364 other => other.to_string(),
365 }
366 }
367}
368
369#[derive(Debug, Clone, Copy, PartialEq)]
371pub struct StatsResult {
372 pub count: u64,
373 pub min: Option<f64>,
375 pub max: Option<f64>,
376 pub avg: Option<f64>,
377 pub sum: f64,
378}
379
380fn string_at(body: &Json, path: &str) -> String {
381 body.get(path).and_then(Json::as_str).unwrap_or_default().to_string()
382}
383
384fn u64_at(body: &Json, path: &str) -> u64 {
385 body.get(path).and_then(Json::as_i64).unwrap_or(0).max(0) as u64
386}
387
388fn u32_at(body: &Json, path: &str) -> u32 {
389 u64_at(body, path) as u32
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 fn parse(body: &str) -> SearchResults {
398 SearchResults::parse(&Json::parse(body).expect("valid JSON in a test")).unwrap()
399 }
400
401 #[test]
402 fn reads_the_hits_a_real_search_returns() {
403 let results = parse(
404 r#"{"took":4,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":2,"relation":"eq"},"max_score":0.60706794,"hits":[{"_index":"posts","_id":"1","_score":0.60706794,"_source":{"title":"Rust web frameworks","tags":["rust","web"],"views":120}},{"_index":"posts","_id":"2","_score":0.35667494,"_source":{"title":"Rust and Tokio","tags":["rust"],"views":45}}]}}"#,
405 );
406
407 assert_eq!(results.took, 4);
408 assert_eq!(results.total.value, 2);
409 assert!(results.total.is_exact());
410 assert_eq!(results.max_score, Some(0.60706794));
411 assert_eq!(results.ids(), vec!["1", "2"]);
412 assert_eq!(results.hits[0].string("title"), Some("Rust web frameworks"));
413 assert_eq!(results.hits[0].field("views").and_then(Json::as_i64), Some(120));
414 assert!(!results.is_partial());
415 }
416
417 #[test]
418 fn a_capped_total_is_never_reported_as_the_answer() {
419 let results = parse(
423 r#"{"took":12,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":10000,"relation":"gte"},"max_score":1.0,"hits":[]}}"#,
424 );
425
426 assert_eq!(results.total.relation, TotalRelation::AtLeast);
427 assert!(!results.total.is_exact());
428 assert_eq!(results.total.exact(), None, "a floor must not be handed out as a count");
429 assert_eq!(results.total.to_string(), "at least 10000");
430 }
431
432 #[test]
433 fn an_exact_total_reads_as_a_plain_number() {
434 let results = parse(
435 r#"{"took":1,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":7,"relation":"eq"},"max_score":null,"hits":[]}}"#,
436 );
437
438 assert_eq!(results.total.exact(), Some(7));
439 assert_eq!(results.total.to_string(), "7");
440 assert_eq!(results.max_score, None, "a sorted search scores nothing");
441 }
442
443 #[test]
444 fn an_old_style_integer_total_is_still_read() {
445 let results = parse(
447 r#"{"took":1,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":42,"max_score":1.0,"hits":[]}}"#,
448 );
449
450 assert_eq!(results.total.exact(), Some(42));
451 }
452
453 #[test]
454 fn a_search_that_succeeded_on_only_some_shards_says_so() {
455 let results = parse(
458 r#"{"took":8,"timed_out":false,"_shards":{"total":2,"successful":1,"skipped":0,"failed":1,"failures":[{"shard":0,"index":"posts","node":"abc","reason":{"type":"query_shard_exception","reason":"failed to create query"}}]},"hits":{"total":{"value":1,"relation":"eq"},"max_score":1.0,"hits":[{"_index":"posts","_id":"1","_score":1.0,"_source":{}}]}}"#,
459 );
460
461 assert!(results.is_partial(), "one shard failed and the results are incomplete");
462 assert_eq!(results.shards.failed, 1);
463 assert_eq!(results.shards.failures, vec!["failed to create query".to_string()]);
464 }
465
466 #[test]
467 fn a_timed_out_search_is_partial_too() {
468 let results = parse(
469 r#"{"took":500,"timed_out":true,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":3,"relation":"eq"},"max_score":1.0,"hits":[]}}"#,
470 );
471
472 assert!(results.is_partial());
473 }
474
475 #[test]
476 fn reads_a_terms_aggregation_and_the_stats_nested_inside_it() {
477 let results = parse(
478 r#"{"took":6,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":3,"relation":"eq"},"max_score":null,"hits":[]},"aggregations":{"by_tag":{"doc_count_error_upper_bound":0,"sum_other_doc_count":0,"buckets":[{"key":"rust","doc_count":2,"views":{"count":2,"min":45.0,"max":120.0,"avg":82.5,"sum":165.0}},{"key":"web","doc_count":1,"views":{"count":1,"min":120.0,"max":120.0,"avg":120.0,"sum":120.0}}]}}}"#,
479 );
480
481 let tags = results.aggregations.terms("by_tag").unwrap();
482 assert_eq!(tags.buckets.len(), 2);
483 assert_eq!(tags.buckets[0].key_string(), "rust");
484 assert_eq!(tags.count("rust"), 2);
485 assert_eq!(tags.count("nothing"), 0);
486 assert!(!tags.is_approximate());
487
488 let views = tags.buckets[0].aggregations.stats("views").unwrap();
489 assert_eq!(views.count, 2);
490 assert_eq!(views.avg, Some(82.5));
491 assert_eq!(views.sum, 165.0);
492 }
493
494 #[test]
495 fn a_truncated_terms_aggregation_admits_that_it_is_truncated() {
496 let results = parse(
499 r#"{"took":9,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":5000,"relation":"eq"},"max_score":null,"hits":[]},"aggregations":{"by_tag":{"doc_count_error_upper_bound":12,"sum_other_doc_count":4000,"buckets":[{"key":"rust","doc_count":1000}]}}}"#,
500 );
501
502 let tags = results.aggregations.terms("by_tag").unwrap();
503 assert!(tags.is_approximate());
504 assert_eq!(tags.sum_other_doc_count, 4000);
505 }
506
507 #[test]
508 fn stats_over_nothing_are_null_rather_than_zero() {
509 let results = parse(
510 r#"{"took":2,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":0,"relation":"eq"},"max_score":null,"hits":[]},"aggregations":{"price":{"count":0,"min":null,"max":null,"avg":null,"sum":0.0}}}"#,
511 );
512
513 let price = results.aggregations.stats("price").unwrap();
514 assert_eq!(price.count, 0);
515 assert_eq!(price.min, None, "there is no minimum of nothing");
516 assert_eq!(price.avg, None);
517 assert_eq!(price.sum, 0.0);
518 }
519
520 #[test]
521 fn a_single_value_metric_is_read_by_name() {
522 let results = parse(
523 r#"{"took":3,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":3,"relation":"eq"},"max_score":null,"hits":[]},"aggregations":{"authors":{"value":2},"total_views":{"value":285.0}}}"#,
524 );
525
526 assert_eq!(results.aggregations.value("authors"), Some(2.0));
527 assert_eq!(results.aggregations.value("total_views"), Some(285.0));
528 assert_eq!(results.aggregations.value("missing"), None);
529 assert_eq!(results.aggregations.names(), vec!["authors", "total_views"]);
530 }
531
532 #[test]
533 fn a_date_histogram_bucket_reads_as_its_date_and_not_as_epoch_millis() {
534 let results = parse(
535 r#"{"took":5,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":2,"relation":"eq"},"max_score":null,"hits":[]},"aggregations":{"per_day":{"buckets":[{"key_as_string":"2024-05-01T00:00:00.000Z","key":1714521600000,"doc_count":2}]}}}"#,
536 );
537
538 let days = results.aggregations.terms("per_day").unwrap();
539 assert_eq!(days.buckets[0].key_string(), "2024-05-01T00:00:00.000Z");
540 assert_eq!(days.buckets[0].doc_count, 2);
541 }
542
543 #[test]
544 fn sort_values_come_back_for_paging_past_the_result_window() {
545 let results = parse(
546 r#"{"took":2,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":2,"relation":"eq"},"max_score":null,"hits":[{"_index":"posts","_id":"2","_score":null,"_source":{"title":"b"},"sort":[1714521600000,"2"]}]}}"#,
547 );
548
549 assert_eq!(results.hits[0].sort.len(), 2);
550 assert_eq!(results.hits[0].sort[1].as_str(), Some("2"));
551 }
552
553 #[test]
554 fn a_reply_with_no_hits_object_is_a_shape_we_do_not_understand() {
555 let error = SearchResults::parse(&Json::parse(r#"{"took":1}"#).unwrap()).unwrap_err();
556 assert!(matches!(error, SearchError::Malformed { .. }), "got {error:?}");
557 }
558}