diff --git a/meilisearch/tests/auth/tenant_token_multi_search.rs b/meilisearch/tests/auth/tenant_token_multi_search.rs index 81146d14e..e994aa3bc 100644 --- a/meilisearch/tests/auth/tenant_token_multi_search.rs +++ b/meilisearch/tests/auth/tenant_token_multi_search.rs @@ -310,6 +310,23 @@ macro_rules! compute_authorized_single_search { tenant_token, key_content ); + + // federated + let (response, code) = server.multi_search(json!({"federation": {}, "queries" : [{"indexUid": "sales", "filter": $filter}]})).await; + assert_eq!( + 200, code, + "{} using tenant_token: {:?} generated with parent_key: {:?}", + response, tenant_token, key_content + ); + assert_eq!( + // same count as the search is federated over a single query + $expected_count, + response["hits"].as_array().unwrap().len(), + "{} using tenant_token: {:?} generated with parent_key: {:?}", + response, + tenant_token, + key_content + ); } } }; @@ -375,6 +392,25 @@ macro_rules! compute_authorized_multiple_search { tenant_token, key_content ); + + let (response, code) = server.multi_search(json!({"federation": {}, "queries" : [ + {"indexUid": "sales", "filter": $filter1}, + {"indexUid": "products", "filter": $filter2}, + ]})).await; + assert_eq!( + code, 200, + "{} using tenant_token: {:?} generated with parent_key: {:?}", + response, tenant_token, key_content + ); + assert_eq!( + response["hits"].as_array().unwrap().len(), + // sum of counts as the search is federated across to queries in different indexes + $expected_count1 + $expected_count2, + "{} using tenant_token: {:?} generated with parent_key: {:?}", + response, + tenant_token, + key_content + ); } } }; @@ -433,6 +469,24 @@ macro_rules! compute_forbidden_single_search { "{} using tenant_token: {:?} generated with parent_key: {:?}", response, tenant_token, key_content ); + + let (mut response, code) = server.multi_search(json!({"federation": {}, "queries" : [{"indexUid": "sales"}]})).await; + if failed_query_index.is_none() && !response["message"].is_null() { + response["message"] = serde_json::json!(null); + } + assert_eq!( + response, + invalid_response(failed_query_index), + "{} using tenant_token: {:?} generated with parent_key: {:?}", + response, + tenant_token, + key_content + ); + assert_eq!( + code, 403, + "{} using tenant_token: {:?} generated with parent_key: {:?}", + response, tenant_token, key_content + ); } } }; @@ -494,6 +548,27 @@ macro_rules! compute_forbidden_multiple_search { "{} using tenant_token: {:?} generated with parent_key: {:?}", response, tenant_token, key_content ); + + let (mut response, code) = server.multi_search(json!({"federation": {}, "queries" : [ + {"indexUid": "sales"}, + {"indexUid": "products"}, + ]})).await; + if failed_query_index.is_none() && !response["message"].is_null() { + response["message"] = serde_json::json!(null); + } + assert_eq!( + response, + invalid_response(failed_query_index), + "{} using tenant_token: {:?} generated with parent_key: {:?}", + response, + tenant_token, + key_content + ); + assert_eq!( + code, 403, + "{} using tenant_token: {:?} generated with parent_key: {:?}", + response, tenant_token, key_content + ); } } }; diff --git a/meilisearch/tests/search/mod.rs b/meilisearch/tests/search/mod.rs index e239ff767..181e0937f 100644 --- a/meilisearch/tests/search/mod.rs +++ b/meilisearch/tests/search/mod.rs @@ -132,6 +132,79 @@ static NESTED_DOCUMENTS: Lazy = Lazy::new(|| { ]) }); +static FRUITS_DOCUMENTS: Lazy = Lazy::new(|| { + json!([ + { + "name": "Exclusive sale: green apple", + "id": "green-apple-boosted", + "BOOST": true + }, + { + "name": "Pear", + "id": "pear", + }, + { + "name": "Red apple gala", + "id": "red-apple-gala", + }, + { + "name": "Exclusive sale: Red Tomato", + "id": "red-tomatoes-boosted", + "BOOST": true + }, + { + "name": "Exclusive sale: Red delicious apple", + "id": "red-delicious-boosted", + "BOOST": true, + } + ]) +}); + +static VECTOR_DOCUMENTS: Lazy = Lazy::new(|| { + json!([ + { + "id": "A", + "description": "the dog barks at the cat", + "_vectors": { + // dimensions [canine, feline, young] + "animal": [0.9, 0.8, 0.05], + // dimensions [negative/positive, energy] + "sentiment": [-0.1, 0.55] + } + }, + { + "id": "B", + "description": "the kitten scratched the beagle", + "_vectors": { + // dimensions [canine, feline, young] + "animal": [0.8, 0.9, 0.5], + // dimensions [negative/positive, energy] + "sentiment": [-0.2, 0.65] + } + }, + { + "id": "C", + "description": "the dog had to stay alone today", + "_vectors": { + // dimensions [canine, feline, young] + "animal": [0.85, 0.02, 0.1], + // dimensions [negative/positive, energy] + "sentiment": [-1.0, 0.1] + } + }, + { + "id": "D", + "description": "the little boy pets the puppy", + "_vectors": { + // dimensions [canine, feline, young] + "animal": [0.8, 0.09, 0.8], + // dimensions [negative/positive, energy] + "sentiment": [0.8, 0.3] + } + }, + ]) +}); + #[actix_rt::test] async fn simple_placeholder_search() { let server = Server::new().await; diff --git a/meilisearch/tests/search/multi.rs b/meilisearch/tests/search/multi.rs index b5cf8f476..e0158b7c3 100644 --- a/meilisearch/tests/search/multi.rs +++ b/meilisearch/tests/search/multi.rs @@ -1,8 +1,9 @@ use meili_snap::{json_string, snapshot}; -use super::{DOCUMENTS, NESTED_DOCUMENTS}; +use super::{DOCUMENTS, FRUITS_DOCUMENTS, NESTED_DOCUMENTS}; use crate::common::Server; use crate::json; +use crate::search::{SCORE_DOCUMENTS, VECTOR_DOCUMENTS}; #[actix_rt::test] async fn search_empty_list() { @@ -17,6 +18,23 @@ async fn search_empty_list() { "###); } +#[actix_rt::test] +async fn federation_empty_list() { + let server = Server::new().await; + + let (response, code) = server.multi_search(json!({"federation": {}, "queries": []})).await; + snapshot!(code, @"200 OK"); + snapshot!(json_string!(response, {".processingTimeMs" => "[time]"}), @r###" + { + "hits": [], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 0 + } + "###); +} + #[actix_rt::test] async fn search_json_object() { let server = Server::new().await; @@ -33,6 +51,22 @@ async fn search_json_object() { "###); } +#[actix_rt::test] +async fn federation_no_queries() { + let server = Server::new().await; + + let (response, code) = server.multi_search(json!({"federation": {}})).await; + snapshot!(code, @"400 Bad Request"); + snapshot!(json_string!(response), @r###" + { + "message": "Missing field `queries`", + "code": "bad_request", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#bad_request" + } + "###); +} + #[actix_rt::test] async fn search_json_array() { let server = Server::new().await; @@ -113,6 +147,184 @@ async fn simple_search_single_index() { "###); } +#[actix_rt::test] +async fn federation_single_search_single_index() { + let server = Server::new().await; + let index = server.index("test"); + + let documents = DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(0).await; + + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "test", "q": "glass"}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]", ".**._rankingScore" => "[score]" }, @r###" + { + "hits": [ + { + "title": "Gläss", + "id": "450465", + "_vectors": { + "manual": [ + -100.0, + 340.0, + 90.0 + ] + }, + "_federation": { + "indexUid": "test", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + } + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 1 + } + "###); +} + +#[actix_rt::test] +async fn federation_multiple_search_single_index() { + let server = Server::new().await; + let index = server.index("test"); + + let documents = SCORE_DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(0).await; + + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid": "test", "q": "the bat"}, + {"indexUid": "test", "q": "badman returns"}, + {"indexUid" : "test", "q": "batman"}, + {"indexUid": "test", "q": "batman returns"}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]", ".**._rankingScore" => "[score]" }, @r###" + { + "hits": [ + { + "title": "Batman", + "id": "D", + "_federation": { + "indexUid": "test", + "queriesPosition": 2, + "weightedRankingScore": 1.0 + } + }, + { + "title": "Batman Returns", + "id": "C", + "_federation": { + "indexUid": "test", + "queriesPosition": 3, + "weightedRankingScore": 1.0 + } + }, + { + "title": "Batman the dark knight returns: Part 1", + "id": "A", + "_federation": { + "indexUid": "test", + "queriesPosition": 2, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "title": "Batman the dark knight returns: Part 2", + "id": "B", + "_federation": { + "indexUid": "test", + "queriesPosition": 2, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "title": "Badman", + "id": "E", + "_federation": { + "indexUid": "test", + "queriesPosition": 1, + "weightedRankingScore": 0.5 + } + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 5 + } + "###); +} + +#[actix_rt::test] +async fn federation_two_search_single_index() { + let server = Server::new().await; + let index = server.index("test"); + + let documents = DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(0).await; + + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "test", "q": "glass"}, + {"indexUid": "test", "q": "captain"}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]", ".**._rankingScore" => "[score]" }, @r###" + { + "hits": [ + { + "title": "Gläss", + "id": "450465", + "_vectors": { + "manual": [ + -100.0, + 340.0, + 90.0 + ] + }, + "_federation": { + "indexUid": "test", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + } + }, + { + "title": "Captain Marvel", + "id": "299537", + "_vectors": { + "manual": [ + 1.0, + 2.0, + 54.0 + ] + }, + "_federation": { + "indexUid": "test", + "queriesPosition": 1, + "weightedRankingScore": 0.9848484848484848 + } + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 2 + } + "###); +} + #[actix_rt::test] async fn simple_search_missing_index_uid() { let server = Server::new().await; @@ -138,6 +350,31 @@ async fn simple_search_missing_index_uid() { "###); } +#[actix_rt::test] +async fn federation_simple_search_missing_index_uid() { + let server = Server::new().await; + let index = server.index("test"); + + let documents = DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(0).await; + + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"q": "glass"}, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + insta::assert_json_snapshot!(response, @r###" + { + "message": "Missing field `indexUid` inside `.queries[0]`", + "code": "missing_index_uid", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#missing_index_uid" + } + "###); +} + #[actix_rt::test] async fn simple_search_illegal_index_uid() { let server = Server::new().await; @@ -163,6 +400,31 @@ async fn simple_search_illegal_index_uid() { "###); } +#[actix_rt::test] +async fn federation_search_illegal_index_uid() { + let server = Server::new().await; + let index = server.index("test"); + + let documents = DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(0).await; + + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid": "hé", "q": "glass"}, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + insta::assert_json_snapshot!(response, @r###" + { + "message": "Invalid value at `.queries[0].indexUid`: `hé` is not a valid index uid. Index uid can be an integer or a string containing only alphanumeric characters, hyphens (-) and underscores (_).", + "code": "invalid_index_uid", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_index_uid" + } + "###); +} + #[actix_rt::test] async fn simple_search_two_indexes() { let server = Server::new().await; @@ -266,6 +528,352 @@ async fn simple_search_two_indexes() { "###); } +#[actix_rt::test] +async fn federation_two_search_two_indexes() { + let server = Server::new().await; + let index = server.index("test"); + + let documents = DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(0).await; + + let index = server.index("nested"); + let documents = NESTED_DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(1).await; + + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "test", "q": "glass"}, + {"indexUid": "nested", "q": "pésti"}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]", ".**._rankingScore" => "[score]" }, @r###" + { + "hits": [ + { + "title": "Gläss", + "id": "450465", + "_vectors": { + "manual": [ + -100.0, + 340.0, + 90.0 + ] + }, + "_federation": { + "indexUid": "test", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + } + }, + { + "id": 852, + "father": "jean", + "mother": "michelle", + "doggos": [ + { + "name": "bobby", + "age": 2 + }, + { + "name": "buddy", + "age": 4 + } + ], + "cattos": "pésti", + "_vectors": { + "manual": [ + 1.0, + 2.0, + 3.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + } + }, + { + "id": 654, + "father": "pierre", + "mother": "sabine", + "doggos": [ + { + "name": "gros bill", + "age": 8 + } + ], + "cattos": [ + "simba", + "pestiféré" + ], + "_vectors": { + "manual": [ + 1.0, + 2.0, + 54.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 1, + "weightedRankingScore": 0.7803030303030303 + } + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 3 + } + "###); +} + +#[actix_rt::test] +async fn federation_multiple_search_multiple_indexes() { + let server = Server::new().await; + let index = server.index("test"); + + let documents = DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(0).await; + + let index = server.index("nested"); + let documents = NESTED_DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(1).await; + + let index = server.index("score"); + let documents = SCORE_DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(2).await; + + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "test", "q": "glass"}, + {"indexUid" : "test", "q": "captain"}, + {"indexUid": "nested", "q": "pésti"}, + {"indexUid" : "test", "q": "Escape"}, + {"indexUid": "nested", "q": "jean"}, + {"indexUid": "score", "q": "jean"}, + {"indexUid": "test", "q": "the bat"}, + {"indexUid": "score", "q": "the bat"}, + {"indexUid": "score", "q": "badman returns"}, + {"indexUid" : "score", "q": "batman"}, + {"indexUid": "score", "q": "batman returns"}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]", ".**._rankingScore" => "[score]" }, @r###" + { + "hits": [ + { + "title": "Gläss", + "id": "450465", + "_vectors": { + "manual": [ + -100.0, + 340.0, + 90.0 + ] + }, + "_federation": { + "indexUid": "test", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + } + }, + { + "id": 852, + "father": "jean", + "mother": "michelle", + "doggos": [ + { + "name": "bobby", + "age": 2 + }, + { + "name": "buddy", + "age": 4 + } + ], + "cattos": "pésti", + "_vectors": { + "manual": [ + 1.0, + 2.0, + 3.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 2, + "weightedRankingScore": 1.0 + } + }, + { + "title": "Batman", + "id": "D", + "_federation": { + "indexUid": "score", + "queriesPosition": 9, + "weightedRankingScore": 1.0 + } + }, + { + "title": "Batman Returns", + "id": "C", + "_federation": { + "indexUid": "score", + "queriesPosition": 10, + "weightedRankingScore": 1.0 + } + }, + { + "title": "Captain Marvel", + "id": "299537", + "_vectors": { + "manual": [ + 1.0, + 2.0, + 54.0 + ] + }, + "_federation": { + "indexUid": "test", + "queriesPosition": 1, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "title": "Escape Room", + "id": "522681", + "_vectors": { + "manual": [ + 10.0, + -23.0, + 32.0 + ] + }, + "_federation": { + "indexUid": "test", + "queriesPosition": 3, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "id": 951, + "father": "jean-baptiste", + "mother": "sophie", + "doggos": [ + { + "name": "turbo", + "age": 5 + }, + { + "name": "fast", + "age": 6 + } + ], + "cattos": [ + "moumoute", + "gomez" + ], + "_vectors": { + "manual": [ + 10.0, + 23.0, + 32.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 4, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "title": "Batman the dark knight returns: Part 1", + "id": "A", + "_federation": { + "indexUid": "score", + "queriesPosition": 9, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "title": "Batman the dark knight returns: Part 2", + "id": "B", + "_federation": { + "indexUid": "score", + "queriesPosition": 9, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "id": 654, + "father": "pierre", + "mother": "sabine", + "doggos": [ + { + "name": "gros bill", + "age": 8 + } + ], + "cattos": [ + "simba", + "pestiféré" + ], + "_vectors": { + "manual": [ + 1.0, + 2.0, + 54.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 2, + "weightedRankingScore": 0.7803030303030303 + } + }, + { + "title": "Badman", + "id": "E", + "_federation": { + "indexUid": "score", + "queriesPosition": 8, + "weightedRankingScore": 0.5 + } + }, + { + "title": "How to Train Your Dragon: The Hidden World", + "id": "166428", + "_vectors": { + "manual": [ + -100.0, + 231.0, + 32.0 + ] + }, + "_federation": { + "indexUid": "test", + "queriesPosition": 6, + "weightedRankingScore": 0.4166666666666667 + } + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 12 + } + "###); +} + #[actix_rt::test] async fn search_one_index_doesnt_exist() { let server = Server::new().await; @@ -292,6 +900,32 @@ async fn search_one_index_doesnt_exist() { "###); } +#[actix_rt::test] +async fn federation_one_index_doesnt_exist() { + let server = Server::new().await; + let index = server.index("test"); + + let documents = DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(0).await; + + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "test", "q": "glass"}, + {"indexUid": "nested", "q": "pésti"}, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(json_string!(response), @r###" + { + "message": "Inside `.queries[1]`: Index `nested` not found.", + "code": "index_not_found", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#index_not_found" + } + "###); +} + #[actix_rt::test] async fn search_multiple_indexes_dont_exist() { let server = Server::new().await; @@ -313,6 +947,29 @@ async fn search_multiple_indexes_dont_exist() { "###); } +#[actix_rt::test] +async fn federation_multiple_indexes_dont_exist() { + let server = Server::new().await; + + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "test", "q": "glass"}, + {"indexUid": "nested", "q": "pésti"}, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + // order of indexes that are not found depends on the alphabetical order of index names + // the query index is the lowest index with that index + snapshot!(json_string!(response), @r###" + { + "message": "Inside `.queries[1]`: Index `nested` not found.", + "code": "index_not_found", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#index_not_found" + } + "###); +} + #[actix_rt::test] async fn search_one_query_error() { let server = Server::new().await; @@ -345,6 +1002,70 @@ async fn search_one_query_error() { "###); } +#[actix_rt::test] +async fn federation_one_query_error() { + let server = Server::new().await; + + let index = server.index("test"); + + let documents = DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(0).await; + + let index = server.index("nested"); + let documents = NESTED_DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(1).await; + + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "test", "q": "glass"}, + {"indexUid": "nested", "q": "pésti", "filter": ["title = toto"]}, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(json_string!(response), @r###" + { + "message": "Inside `.queries[1]`: Attribute `title` is not filterable. This index does not have configured filterable attributes.\n1:6 title = toto", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); +} + +#[actix_rt::test] +async fn federation_one_query_sort_error() { + let server = Server::new().await; + + let index = server.index("test"); + + let documents = DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(0).await; + + let index = server.index("nested"); + let documents = NESTED_DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(1).await; + + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "test", "q": "glass"}, + {"indexUid": "nested", "q": "pésti", "sort": ["doggos:desc"]}, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(json_string!(response), @r###" + { + "message": "Inside `.queries[1]`: Attribute `doggos` is not sortable. This index does not have configured sortable attributes.", + "code": "invalid_search_sort", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_sort" + } + "###); +} + #[actix_rt::test] async fn search_multiple_query_errors() { let server = Server::new().await; @@ -376,3 +1097,3227 @@ async fn search_multiple_query_errors() { } "###); } + +#[actix_rt::test] +async fn federation_multiple_query_errors() { + let server = Server::new().await; + + let index = server.index("test"); + + let documents = DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(0).await; + + let index = server.index("nested"); + let documents = NESTED_DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(1).await; + + let (response, code) = server + .multi_search(json!({"queries": [ + {"indexUid" : "test", "q": "glass", "filter": ["title = toto"]}, + {"indexUid": "nested", "q": "pésti", "filter": ["doggos IN [intel, kefir]"]}, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(json_string!(response), @r###" + { + "message": "Inside `.queries[0]`: Attribute `title` is not filterable. This index does not have configured filterable attributes.\n1:6 title = toto", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); +} + +#[actix_rt::test] +async fn federation_multiple_query_sort_errors() { + let server = Server::new().await; + + let index = server.index("test"); + + let documents = DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(0).await; + + let index = server.index("nested"); + let documents = NESTED_DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(1).await; + + let (response, code) = server + .multi_search(json!({"queries": [ + {"indexUid" : "test", "q": "glass", "sort": ["title:desc"]}, + {"indexUid": "nested", "q": "pésti", "sort": ["doggos:desc"]}, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(json_string!(response), @r###" + { + "message": "Inside `.queries[0]`: Attribute `title` is not sortable. This index does not have configured sortable attributes.", + "code": "invalid_search_sort", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_sort" + } + "###); +} + +#[actix_rt::test] +async fn federation_multiple_query_errors_interleaved() { + let server = Server::new().await; + + let index = server.index("test"); + + let documents = DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(0).await; + + let index = server.index("nested"); + let documents = NESTED_DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(1).await; + + let (response, code) = server + .multi_search(json!({"queries": [ + {"indexUid" : "test", "q": "glass"}, + {"indexUid": "nested", "q": "pésti", "filter": ["doggos IN [intel, kefir]"]}, + {"indexUid" : "test", "q": "glass", "filter": ["title = toto"]}, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(json_string!(response), @r###" + { + "message": "Inside `.queries[1]`: Attribute `doggos` is not filterable. This index does not have configured filterable attributes.\n1:7 doggos IN [intel, kefir]", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); +} + +#[actix_rt::test] +async fn federation_multiple_query_sort_errors_interleaved() { + let server = Server::new().await; + + let index = server.index("test"); + + let documents = DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(0).await; + + let index = server.index("nested"); + let documents = NESTED_DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(1).await; + + let (response, code) = server + .multi_search(json!({"queries": [ + {"indexUid" : "test", "q": "glass"}, + {"indexUid": "nested", "q": "pésti", "sort": ["doggos:desc"]}, + {"indexUid" : "test", "q": "glass", "sort": ["title:desc"]}, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(json_string!(response), @r###" + { + "message": "Inside `.queries[1]`: Attribute `doggos` is not sortable. This index does not have configured sortable attributes.", + "code": "invalid_search_sort", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_sort" + } + "###); +} + +#[actix_rt::test] +async fn federation_filter() { + let server = Server::new().await; + + let index = server.index("fruits"); + + let documents = FRUITS_DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + let (value, _) = index + .update_settings( + json!({"searchableAttributes": ["name"], "filterableAttributes": ["BOOST"]}), + ) + .await; + index.wait_task(value.uid()).await; + + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "fruits", "q": "apple red", "filter": "BOOST = true", "showRankingScore": true, "federationOptions": {"weight": 3.0}}, + {"indexUid": "fruits", "q": "apple red", "showRankingScore": true}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "hits": [ + { + "name": "Exclusive sale: Red delicious apple", + "id": "red-delicious-boosted", + "BOOST": true, + "_federation": { + "indexUid": "fruits", + "queriesPosition": 0, + "weightedRankingScore": 2.7281746031746033 + }, + "_rankingScore": 0.9093915343915344 + }, + { + "name": "Exclusive sale: green apple", + "id": "green-apple-boosted", + "BOOST": true, + "_federation": { + "indexUid": "fruits", + "queriesPosition": 0, + "weightedRankingScore": 1.318181818181818 + }, + "_rankingScore": 0.4393939393939394 + }, + { + "name": "Red apple gala", + "id": "red-apple-gala", + "_federation": { + "indexUid": "fruits", + "queriesPosition": 1, + "weightedRankingScore": 0.953042328042328 + }, + "_rankingScore": 0.953042328042328 + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 3 + } + "###); +} + +#[actix_rt::test] +async fn federation_sort_same_indexes_same_criterion_same_direction() { + let server = Server::new().await; + + let index = server.index("nested"); + + let documents = NESTED_DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + let (value, _) = index + .update_settings(json!({ + "sortableAttributes": ["mother"], + "rankingRules": [ + "sort", + "words", + "typo", + "proximity", + "attribute", + "exactness" + ] + })) + .await; + index.wait_task(value.uid()).await; + + // two identical placeholder search should have all results from first query + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "nested", "q": "", "sort": ["mother:asc"], "showRankingScore": true }, + {"indexUid" : "nested", "q": "", "sort": ["mother:asc"], "showRankingScore": true }, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "hits": [ + { + "id": 852, + "father": "jean", + "mother": "michelle", + "doggos": [ + { + "name": "bobby", + "age": 2 + }, + { + "name": "buddy", + "age": 4 + } + ], + "cattos": "pésti", + "_vectors": { + "manual": [ + 1.0, + 2.0, + 3.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "id": 750, + "father": "romain", + "mother": "michelle", + "cattos": [ + "enigma" + ], + "_vectors": { + "manual": [ + 10.0, + 23.0, + 32.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "id": 654, + "father": "pierre", + "mother": "sabine", + "doggos": [ + { + "name": "gros bill", + "age": 8 + } + ], + "cattos": [ + "simba", + "pestiféré" + ], + "_vectors": { + "manual": [ + 1.0, + 2.0, + 54.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "id": 951, + "father": "jean-baptiste", + "mother": "sophie", + "doggos": [ + { + "name": "turbo", + "age": 5 + }, + { + "name": "fast", + "age": 6 + } + ], + "cattos": [ + "moumoute", + "gomez" + ], + "_vectors": { + "manual": [ + 10.0, + 23.0, + 32.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 4 + } + "###); + + // mix and match query + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "nested", "q": "pésti", "sort": ["mother:asc"], "showRankingScore": true }, + {"indexUid" : "nested", "q": "jean", "sort": ["mother:asc"], "showRankingScore": true }, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "hits": [ + { + "id": 852, + "father": "jean", + "mother": "michelle", + "doggos": [ + { + "name": "bobby", + "age": 2 + }, + { + "name": "buddy", + "age": 4 + } + ], + "cattos": "pésti", + "_vectors": { + "manual": [ + 1.0, + 2.0, + 3.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "id": 654, + "father": "pierre", + "mother": "sabine", + "doggos": [ + { + "name": "gros bill", + "age": 8 + } + ], + "cattos": [ + "simba", + "pestiféré" + ], + "_vectors": { + "manual": [ + 1.0, + 2.0, + 54.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 0, + "weightedRankingScore": 0.7803030303030303 + }, + "_rankingScore": 0.7803030303030303 + }, + { + "id": 951, + "father": "jean-baptiste", + "mother": "sophie", + "doggos": [ + { + "name": "turbo", + "age": 5 + }, + { + "name": "fast", + "age": 6 + } + ], + "cattos": [ + "moumoute", + "gomez" + ], + "_vectors": { + "manual": [ + 10.0, + 23.0, + 32.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 1, + "weightedRankingScore": 0.9848484848484848 + }, + "_rankingScore": 0.9848484848484848 + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 3 + } + "###); +} + +#[actix_rt::test] +async fn federation_sort_same_indexes_same_criterion_opposite_direction() { + let server = Server::new().await; + + let index = server.index("nested"); + + let documents = NESTED_DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + let (value, _) = index + .update_settings(json!({ + "sortableAttributes": ["mother"], + "rankingRules": [ + "sort", + "words", + "typo", + "proximity", + "attribute", + "exactness" + ] + })) + .await; + index.wait_task(value.uid()).await; + + // two identical placeholder search should have all results from first query + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "nested", "q": "", "sort": ["mother:asc"], "showRankingScore": true }, + {"indexUid" : "nested", "q": "", "sort": ["mother:desc"], "showRankingScore": true }, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "message": "Inside `.queries[1]`: The results of queries #0 and #1 are incompatible: \n 1. `queries[0].sort[0]`, `nested.rankingRules[0]`: ascending sort rule(s) on field `mother`\n 2. `queries[1].sort[0]`, `nested.rankingRules[0]`: descending sort rule(s) on field `mother`\n - cannot compare two sort rules in opposite directions\n - note: The ranking rules of query #0 were modified during canonicalization:\n 1. Removed relevancy rule `words` at position #1 in ranking rules because the query is a placeholder search (`q`: \"\")\n 2. Removed relevancy rule `typo` at position #2 in ranking rules because the query is a placeholder search (`q`: \"\")\n 3. Removed relevancy rule `proximity` at position #3 in ranking rules because the query is a placeholder search (`q`: \"\")\n 4. Removed relevancy rule `attribute` at position #4 in ranking rules because the query is a placeholder search (`q`: \"\")\n 5. Removed relevancy rule `exactness` at position #5 in ranking rules because the query is a placeholder search (`q`: \"\")\n - note: The ranking rules of query #1 were modified during canonicalization:\n 1. Removed relevancy rule `words` at position #1 in ranking rules because the query is a placeholder search (`q`: \"\")\n 2. Removed relevancy rule `typo` at position #2 in ranking rules because the query is a placeholder search (`q`: \"\")\n 3. Removed relevancy rule `proximity` at position #3 in ranking rules because the query is a placeholder search (`q`: \"\")\n 4. Removed relevancy rule `attribute` at position #4 in ranking rules because the query is a placeholder search (`q`: \"\")\n 5. Removed relevancy rule `exactness` at position #5 in ranking rules because the query is a placeholder search (`q`: \"\")\n", + "code": "invalid_multi_search_query_ranking_rules", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_multi_search_query_ranking_rules" + } + "###); + + // mix and match query: should be ranked by ranking score + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "nested", "q": "pésti", "sort": ["mother:asc"], "showRankingScore": true }, + {"indexUid" : "nested", "q": "jean", "sort": ["mother:desc"], "showRankingScore": true }, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "message": "Inside `.queries[1]`: The results of queries #0 and #1 are incompatible: \n 1. `queries[0].sort[0]`, `nested.rankingRules[0]`: ascending sort rule(s) on field `mother`\n 2. `queries[1].sort[0]`, `nested.rankingRules[0]`: descending sort rule(s) on field `mother`\n - cannot compare two sort rules in opposite directions\n", + "code": "invalid_multi_search_query_ranking_rules", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_multi_search_query_ranking_rules" + } + "###); +} + +#[actix_rt::test] +async fn federation_sort_same_indexes_different_criterion_same_direction() { + let server = Server::new().await; + + let index = server.index("nested"); + + let documents = NESTED_DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + let (value, _) = index + .update_settings(json!({ + "sortableAttributes": ["mother", "father"], + "rankingRules": [ + "sort", + "words", + "typo", + "proximity", + "attribute", + "exactness" + ] + })) + .await; + index.wait_task(value.uid()).await; + + // return mothers and fathers ordered accross fields. + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "nested", "q": "", "sort": ["mother:asc"], "showRankingScore": true }, + {"indexUid" : "nested", "q": "", "sort": ["father:asc"], "showRankingScore": true }, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "hits": [ + { + "id": 852, + "father": "jean", + "mother": "michelle", + "doggos": [ + { + "name": "bobby", + "age": 2 + }, + { + "name": "buddy", + "age": 4 + } + ], + "cattos": "pésti", + "_vectors": { + "manual": [ + 1.0, + 2.0, + 3.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "id": 951, + "father": "jean-baptiste", + "mother": "sophie", + "doggos": [ + { + "name": "turbo", + "age": 5 + }, + { + "name": "fast", + "age": 6 + } + ], + "cattos": [ + "moumoute", + "gomez" + ], + "_vectors": { + "manual": [ + 10.0, + 23.0, + 32.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "id": 750, + "father": "romain", + "mother": "michelle", + "cattos": [ + "enigma" + ], + "_vectors": { + "manual": [ + 10.0, + 23.0, + 32.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "id": 654, + "father": "pierre", + "mother": "sabine", + "doggos": [ + { + "name": "gros bill", + "age": 8 + } + ], + "cattos": [ + "simba", + "pestiféré" + ], + "_vectors": { + "manual": [ + 1.0, + 2.0, + 54.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 4 + } + "###); + + // mix and match query: will be sorted across mother and father names + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "nested", "q": "pésti", "sort": ["mother:desc"], "showRankingScore": true }, + {"indexUid" : "nested", "q": "jean-bap", "sort": ["father:desc"], "showRankingScore": true }, + {"indexUid" : "nested", "q": "jea", "sort": ["father:desc"], "showRankingScore": true }, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "hits": [ + { + "id": 654, + "father": "pierre", + "mother": "sabine", + "doggos": [ + { + "name": "gros bill", + "age": 8 + } + ], + "cattos": [ + "simba", + "pestiféré" + ], + "_vectors": { + "manual": [ + 1.0, + 2.0, + 54.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 0, + "weightedRankingScore": 0.7803030303030303 + }, + "_rankingScore": 0.7803030303030303 + }, + { + "id": 852, + "father": "jean", + "mother": "michelle", + "doggos": [ + { + "name": "bobby", + "age": 2 + }, + { + "name": "buddy", + "age": 4 + } + ], + "cattos": "pésti", + "_vectors": { + "manual": [ + 1.0, + 2.0, + 3.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "id": 951, + "father": "jean-baptiste", + "mother": "sophie", + "doggos": [ + { + "name": "turbo", + "age": 5 + }, + { + "name": "fast", + "age": 6 + } + ], + "cattos": [ + "moumoute", + "gomez" + ], + "_vectors": { + "manual": [ + 10.0, + 23.0, + 32.0 + ] + }, + "_federation": { + "indexUid": "nested", + "queriesPosition": 1, + "weightedRankingScore": 0.9991181657848324 + }, + "_rankingScore": 0.9991181657848324 + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 3 + } + "###); +} + +#[actix_rt::test] +async fn federation_sort_same_indexes_different_criterion_opposite_direction() { + let server = Server::new().await; + + let index = server.index("nested"); + + let documents = NESTED_DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + let (value, _) = index + .update_settings(json!({ + "sortableAttributes": ["mother", "father"], + "rankingRules": [ + "sort", + "words", + "typo", + "proximity", + "attribute", + "exactness" + ] + })) + .await; + index.wait_task(value.uid()).await; + + // two identical placeholder search should have all results from first query + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "nested", "q": "", "sort": ["mother:asc"], "showRankingScore": true }, + {"indexUid" : "nested", "q": "", "sort": ["father:desc"], "showRankingScore": true }, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "message": "Inside `.queries[1]`: The results of queries #0 and #1 are incompatible: \n 1. `queries[0].sort[0]`, `nested.rankingRules[0]`: ascending sort rule(s) on field `mother`\n 2. `queries[1].sort[0]`, `nested.rankingRules[0]`: descending sort rule(s) on field `father`\n - cannot compare two sort rules in opposite directions\n - note: The ranking rules of query #0 were modified during canonicalization:\n 1. Removed relevancy rule `words` at position #1 in ranking rules because the query is a placeholder search (`q`: \"\")\n 2. Removed relevancy rule `typo` at position #2 in ranking rules because the query is a placeholder search (`q`: \"\")\n 3. Removed relevancy rule `proximity` at position #3 in ranking rules because the query is a placeholder search (`q`: \"\")\n 4. Removed relevancy rule `attribute` at position #4 in ranking rules because the query is a placeholder search (`q`: \"\")\n 5. Removed relevancy rule `exactness` at position #5 in ranking rules because the query is a placeholder search (`q`: \"\")\n - note: The ranking rules of query #1 were modified during canonicalization:\n 1. Removed relevancy rule `words` at position #1 in ranking rules because the query is a placeholder search (`q`: \"\")\n 2. Removed relevancy rule `typo` at position #2 in ranking rules because the query is a placeholder search (`q`: \"\")\n 3. Removed relevancy rule `proximity` at position #3 in ranking rules because the query is a placeholder search (`q`: \"\")\n 4. Removed relevancy rule `attribute` at position #4 in ranking rules because the query is a placeholder search (`q`: \"\")\n 5. Removed relevancy rule `exactness` at position #5 in ranking rules because the query is a placeholder search (`q`: \"\")\n", + "code": "invalid_multi_search_query_ranking_rules", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_multi_search_query_ranking_rules" + } + "###); + + // mix and match query: should be ranked by ranking score + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "nested", "q": "pésti", "sort": ["mother:asc"], "showRankingScore": true }, + {"indexUid" : "nested", "q": "jean", "sort": ["father:desc"], "showRankingScore": true }, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "message": "Inside `.queries[1]`: The results of queries #0 and #1 are incompatible: \n 1. `queries[0].sort[0]`, `nested.rankingRules[0]`: ascending sort rule(s) on field `mother`\n 2. `queries[1].sort[0]`, `nested.rankingRules[0]`: descending sort rule(s) on field `father`\n - cannot compare two sort rules in opposite directions\n", + "code": "invalid_multi_search_query_ranking_rules", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_multi_search_query_ranking_rules" + } + "###); +} + +#[actix_rt::test] +async fn federation_sort_different_indexes_same_criterion_same_direction() { + let server = Server::new().await; + + let index = server.index("movies"); + + let documents = DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + let (value, _) = index + .update_settings(json!({ + "sortableAttributes": ["title"], + "rankingRules": [ + "sort", + "words", + "typo", + "proximity", + "attribute", + "exactness" + ] + })) + .await; + index.wait_task(value.uid()).await; + + let index = server.index("batman"); + + let documents = SCORE_DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + let (value, _) = index + .update_settings(json!({ + "sortableAttributes": ["title"], + "rankingRules": [ + "sort", + "words", + "typo", + "proximity", + "attribute", + "exactness" + ] + })) + .await; + index.wait_task(value.uid()).await; + + // return titles ordered accross indexes + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "movies", "q": "", "sort": ["title:asc"], "showRankingScore": true }, + {"indexUid" : "batman", "q": "", "sort": ["title:asc"], "showRankingScore": true }, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "hits": [ + { + "title": "Badman", + "id": "E", + "_federation": { + "indexUid": "batman", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Batman", + "id": "D", + "_federation": { + "indexUid": "batman", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Batman Returns", + "id": "C", + "_federation": { + "indexUid": "batman", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Batman the dark knight returns: Part 1", + "id": "A", + "_federation": { + "indexUid": "batman", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Batman the dark knight returns: Part 2", + "id": "B", + "_federation": { + "indexUid": "batman", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Captain Marvel", + "id": "299537", + "_vectors": { + "manual": [ + 1.0, + 2.0, + 54.0 + ] + }, + "_federation": { + "indexUid": "movies", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Escape Room", + "id": "522681", + "_vectors": { + "manual": [ + 10.0, + -23.0, + 32.0 + ] + }, + "_federation": { + "indexUid": "movies", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Gläss", + "id": "450465", + "_vectors": { + "manual": [ + -100.0, + 340.0, + 90.0 + ] + }, + "_federation": { + "indexUid": "movies", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "How to Train Your Dragon: The Hidden World", + "id": "166428", + "_vectors": { + "manual": [ + -100.0, + 231.0, + 32.0 + ] + }, + "_federation": { + "indexUid": "movies", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Shazam!", + "id": "287947", + "_vectors": { + "manual": [ + 1.0, + 2.0, + 3.0 + ] + }, + "_federation": { + "indexUid": "movies", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 10 + } + "###); + + // mix and match query: will be sorted across indexes + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "batman", "q": "badman returns", "sort": ["title:desc"], "showRankingScore": true }, + {"indexUid" : "movies", "q": "captain", "sort": ["title:desc"], "showRankingScore": true }, + {"indexUid" : "batman", "q": "the bat", "sort": ["title:desc"], "showRankingScore": true }, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "hits": [ + { + "title": "Captain Marvel", + "id": "299537", + "_vectors": { + "manual": [ + 1.0, + 2.0, + 54.0 + ] + }, + "_federation": { + "indexUid": "movies", + "queriesPosition": 1, + "weightedRankingScore": 0.9848484848484848 + }, + "_rankingScore": 0.9848484848484848 + }, + { + "title": "Batman the dark knight returns: Part 2", + "id": "B", + "_federation": { + "indexUid": "batman", + "queriesPosition": 2, + "weightedRankingScore": 0.9528218694885362 + }, + "_rankingScore": 0.9528218694885362 + }, + { + "title": "Batman the dark knight returns: Part 1", + "id": "A", + "_federation": { + "indexUid": "batman", + "queriesPosition": 2, + "weightedRankingScore": 0.9528218694885362 + }, + "_rankingScore": 0.9528218694885362 + }, + { + "title": "Batman Returns", + "id": "C", + "_federation": { + "indexUid": "batman", + "queriesPosition": 0, + "weightedRankingScore": 0.8317901234567902 + }, + "_rankingScore": 0.8317901234567902 + }, + { + "title": "Batman", + "id": "D", + "_federation": { + "indexUid": "batman", + "queriesPosition": 0, + "weightedRankingScore": 0.23106060606060605 + }, + "_rankingScore": 0.23106060606060605 + }, + { + "title": "Badman", + "id": "E", + "_federation": { + "indexUid": "batman", + "queriesPosition": 0, + "weightedRankingScore": 0.5 + }, + "_rankingScore": 0.5 + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 6 + } + "###); +} + +#[actix_rt::test] +async fn federation_sort_different_ranking_rules() { + let server = Server::new().await; + + let index = server.index("movies"); + + let documents = DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + let (value, _) = index + .update_settings(json!({ + "sortableAttributes": ["title"], + "rankingRules": [ + "sort", + "words", + "typo", + "proximity", + "attribute", + "exactness" + ] + })) + .await; + index.wait_task(value.uid()).await; + + let index = server.index("batman"); + + let documents = SCORE_DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + let (value, _) = index + .update_settings(json!({ + "sortableAttributes": ["title"], + "rankingRules": [ + "words", + "typo", + "proximity", + "attribute", + "sort", + "exactness" + ] + })) + .await; + index.wait_task(value.uid()).await; + + // return titles ordered accross indexes + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "movies", "q": "", "sort": ["title:asc"], "showRankingScore": true }, + {"indexUid" : "batman", "q": "", "sort": ["title:asc"], "showRankingScore": true }, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "hits": [ + { + "title": "Badman", + "id": "E", + "_federation": { + "indexUid": "batman", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Batman", + "id": "D", + "_federation": { + "indexUid": "batman", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Batman Returns", + "id": "C", + "_federation": { + "indexUid": "batman", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Batman the dark knight returns: Part 1", + "id": "A", + "_federation": { + "indexUid": "batman", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Batman the dark knight returns: Part 2", + "id": "B", + "_federation": { + "indexUid": "batman", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Captain Marvel", + "id": "299537", + "_vectors": { + "manual": [ + 1.0, + 2.0, + 54.0 + ] + }, + "_federation": { + "indexUid": "movies", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Escape Room", + "id": "522681", + "_vectors": { + "manual": [ + 10.0, + -23.0, + 32.0 + ] + }, + "_federation": { + "indexUid": "movies", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Gläss", + "id": "450465", + "_vectors": { + "manual": [ + -100.0, + 340.0, + 90.0 + ] + }, + "_federation": { + "indexUid": "movies", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "How to Train Your Dragon: The Hidden World", + "id": "166428", + "_vectors": { + "manual": [ + -100.0, + 231.0, + 32.0 + ] + }, + "_federation": { + "indexUid": "movies", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Shazam!", + "id": "287947", + "_vectors": { + "manual": [ + 1.0, + 2.0, + 3.0 + ] + }, + "_federation": { + "indexUid": "movies", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 10 + } + "###); + + // mix and match query: order difficult to understand + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "batman", "q": "badman returns", "sort": ["title:desc"], "showRankingScore": true }, + {"indexUid" : "movies", "q": "captain", "sort": ["title:desc"], "showRankingScore": true }, + {"indexUid" : "batman", "q": "the bat", "sort": ["title:desc"], "showRankingScore": true }, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "message": "Inside `.queries[1]`: The results of queries #2 and #1 are incompatible: \n 1. `queries[2]`, `batman.rankingRules[0..=3]`: relevancy rule(s) words, typo, proximity, attribute\n 2. `queries[1].sort[0]`, `movies.rankingRules[0]`: descending sort rule(s) on field `title`\n - cannot compare a relevancy rule with a sort rule\n", + "code": "invalid_multi_search_query_ranking_rules", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_multi_search_query_ranking_rules" + } + "###); +} + +#[actix_rt::test] +async fn federation_sort_different_indexes_same_criterion_opposite_direction() { + let server = Server::new().await; + + let index = server.index("movies"); + + let documents = DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + let (value, _) = index + .update_settings(json!({ + "sortableAttributes": ["title"], + "rankingRules": [ + "sort", + "words", + "typo", + "proximity", + "attribute", + "exactness" + ] + })) + .await; + index.wait_task(value.uid()).await; + + let index = server.index("batman"); + + let documents = SCORE_DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + let (value, _) = index + .update_settings(json!({ + "sortableAttributes": ["title"], + "rankingRules": [ + "sort", + "words", + "typo", + "proximity", + "attribute", + "exactness" + ] + })) + .await; + index.wait_task(value.uid()).await; + + // all results from query 0 + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "movies", "q": "", "sort": ["title:asc"], "showRankingScore": true }, + {"indexUid" : "batman", "q": "", "sort": ["title:desc"], "showRankingScore": true }, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "message": "Inside `.queries[0]`: The results of queries #1 and #0 are incompatible: \n 1. `queries[1].sort[0]`, `batman.rankingRules[0]`: descending sort rule(s) on field `title`\n 2. `queries[0].sort[0]`, `movies.rankingRules[0]`: ascending sort rule(s) on field `title`\n - cannot compare two sort rules in opposite directions\n - note: The ranking rules of query #1 were modified during canonicalization:\n 1. Removed relevancy rule `words` at position #1 in ranking rules because the query is a placeholder search (`q`: \"\")\n 2. Removed relevancy rule `typo` at position #2 in ranking rules because the query is a placeholder search (`q`: \"\")\n 3. Removed relevancy rule `proximity` at position #3 in ranking rules because the query is a placeholder search (`q`: \"\")\n 4. Removed relevancy rule `attribute` at position #4 in ranking rules because the query is a placeholder search (`q`: \"\")\n 5. Removed relevancy rule `exactness` at position #5 in ranking rules because the query is a placeholder search (`q`: \"\")\n - note: The ranking rules of query #0 were modified during canonicalization:\n 1. Removed relevancy rule `words` at position #1 in ranking rules because the query is a placeholder search (`q`: \"\")\n 2. Removed relevancy rule `typo` at position #2 in ranking rules because the query is a placeholder search (`q`: \"\")\n 3. Removed relevancy rule `proximity` at position #3 in ranking rules because the query is a placeholder search (`q`: \"\")\n 4. Removed relevancy rule `attribute` at position #4 in ranking rules because the query is a placeholder search (`q`: \"\")\n 5. Removed relevancy rule `exactness` at position #5 in ranking rules because the query is a placeholder search (`q`: \"\")\n", + "code": "invalid_multi_search_query_ranking_rules", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_multi_search_query_ranking_rules" + } + "###); + + // mix and match query: will be sorted by ranking score + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "batman", "q": "badman returns", "sort": ["title:asc"], "showRankingScore": true }, + {"indexUid" : "movies", "q": "captain", "sort": ["title:desc"], "showRankingScore": true }, + {"indexUid" : "batman", "q": "the bat", "sort": ["title:asc"], "showRankingScore": true }, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "message": "Inside `.queries[1]`: The results of queries #2 and #1 are incompatible: \n 1. `queries[2].sort[0]`, `batman.rankingRules[0]`: ascending sort rule(s) on field `title`\n 2. `queries[1].sort[0]`, `movies.rankingRules[0]`: descending sort rule(s) on field `title`\n - cannot compare two sort rules in opposite directions\n", + "code": "invalid_multi_search_query_ranking_rules", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_multi_search_query_ranking_rules" + } + "###); +} + +#[actix_rt::test] +async fn federation_sort_different_indexes_different_criterion_same_direction() { + let server = Server::new().await; + + let index = server.index("movies"); + + let documents = DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + let (value, _) = index + .update_settings(json!({ + "sortableAttributes": ["title"], + "rankingRules": [ + "sort", + "words", + "typo", + "proximity", + "attribute", + "exactness" + ] + })) + .await; + index.wait_task(value.uid()).await; + + let index = server.index("batman"); + + let documents = SCORE_DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + let (value, _) = index + .update_settings(json!({ + "sortableAttributes": ["id"], + "rankingRules": [ + "sort", + "words", + "typo", + "proximity", + "attribute", + "exactness" + ] + })) + .await; + index.wait_task(value.uid()).await; + + // return titles ordered accross indexes + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "movies", "q": "", "sort": ["title:asc"], "showRankingScore": true }, + {"indexUid" : "batman", "q": "", "sort": ["id:asc"], "showRankingScore": true }, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "hits": [ + { + "title": "Batman the dark knight returns: Part 1", + "id": "A", + "_federation": { + "indexUid": "batman", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Batman the dark knight returns: Part 2", + "id": "B", + "_federation": { + "indexUid": "batman", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Batman Returns", + "id": "C", + "_federation": { + "indexUid": "batman", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Captain Marvel", + "id": "299537", + "_vectors": { + "manual": [ + 1.0, + 2.0, + 54.0 + ] + }, + "_federation": { + "indexUid": "movies", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Batman", + "id": "D", + "_federation": { + "indexUid": "batman", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Badman", + "id": "E", + "_federation": { + "indexUid": "batman", + "queriesPosition": 1, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Escape Room", + "id": "522681", + "_vectors": { + "manual": [ + 10.0, + -23.0, + 32.0 + ] + }, + "_federation": { + "indexUid": "movies", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Gläss", + "id": "450465", + "_vectors": { + "manual": [ + -100.0, + 340.0, + 90.0 + ] + }, + "_federation": { + "indexUid": "movies", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "How to Train Your Dragon: The Hidden World", + "id": "166428", + "_vectors": { + "manual": [ + -100.0, + 231.0, + 32.0 + ] + }, + "_federation": { + "indexUid": "movies", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + }, + { + "title": "Shazam!", + "id": "287947", + "_vectors": { + "manual": [ + 1.0, + 2.0, + 3.0 + ] + }, + "_federation": { + "indexUid": "movies", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_rankingScore": 1.0 + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 10 + } + "###); + + // mix and match query: will be sorted across indexes and criterion + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "batman", "q": "badman returns", "sort": ["id:desc"], "showRankingScore": true }, + {"indexUid" : "movies", "q": "captain", "sort": ["title:desc"], "showRankingScore": true }, + {"indexUid" : "batman", "q": "the bat", "sort": ["id:desc"], "showRankingScore": true }, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "hits": [ + { + "title": "Badman", + "id": "E", + "_federation": { + "indexUid": "batman", + "queriesPosition": 0, + "weightedRankingScore": 0.5 + }, + "_rankingScore": 0.5 + }, + { + "title": "Batman", + "id": "D", + "_federation": { + "indexUid": "batman", + "queriesPosition": 0, + "weightedRankingScore": 0.23106060606060605 + }, + "_rankingScore": 0.23106060606060605 + }, + { + "title": "Captain Marvel", + "id": "299537", + "_vectors": { + "manual": [ + 1.0, + 2.0, + 54.0 + ] + }, + "_federation": { + "indexUid": "movies", + "queriesPosition": 1, + "weightedRankingScore": 0.9848484848484848 + }, + "_rankingScore": 0.9848484848484848 + }, + { + "title": "Batman Returns", + "id": "C", + "_federation": { + "indexUid": "batman", + "queriesPosition": 0, + "weightedRankingScore": 0.8317901234567902 + }, + "_rankingScore": 0.8317901234567902 + }, + { + "title": "Batman the dark knight returns: Part 2", + "id": "B", + "_federation": { + "indexUid": "batman", + "queriesPosition": 2, + "weightedRankingScore": 0.9528218694885362 + }, + "_rankingScore": 0.9528218694885362 + }, + { + "title": "Batman the dark knight returns: Part 1", + "id": "A", + "_federation": { + "indexUid": "batman", + "queriesPosition": 2, + "weightedRankingScore": 0.9528218694885362 + }, + "_rankingScore": 0.9528218694885362 + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 6 + } + "###); +} + +#[actix_rt::test] +async fn federation_sort_different_indexes_different_criterion_opposite_direction() { + let server = Server::new().await; + + let index = server.index("movies"); + + let documents = DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + let (value, _) = index + .update_settings(json!({ + "sortableAttributes": ["title"], + "rankingRules": [ + "sort", + "words", + "typo", + "proximity", + "attribute", + "exactness" + ] + })) + .await; + index.wait_task(value.uid()).await; + + let index = server.index("batman"); + + let documents = SCORE_DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + let (value, _) = index + .update_settings(json!({ + "sortableAttributes": ["id"], + "rankingRules": [ + "sort", + "words", + "typo", + "proximity", + "attribute", + "exactness" + ] + })) + .await; + index.wait_task(value.uid()).await; + + // all results from query 0 first + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "movies", "q": "", "sort": ["title:asc"], "showRankingScore": true }, + {"indexUid" : "batman", "q": "", "sort": ["id:desc"], "showRankingScore": true }, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "message": "Inside `.queries[0]`: The results of queries #1 and #0 are incompatible: \n 1. `queries[1].sort[0]`, `batman.rankingRules[0]`: descending sort rule(s) on field `id`\n 2. `queries[0].sort[0]`, `movies.rankingRules[0]`: ascending sort rule(s) on field `title`\n - cannot compare two sort rules in opposite directions\n - note: The ranking rules of query #1 were modified during canonicalization:\n 1. Removed relevancy rule `words` at position #1 in ranking rules because the query is a placeholder search (`q`: \"\")\n 2. Removed relevancy rule `typo` at position #2 in ranking rules because the query is a placeholder search (`q`: \"\")\n 3. Removed relevancy rule `proximity` at position #3 in ranking rules because the query is a placeholder search (`q`: \"\")\n 4. Removed relevancy rule `attribute` at position #4 in ranking rules because the query is a placeholder search (`q`: \"\")\n 5. Removed relevancy rule `exactness` at position #5 in ranking rules because the query is a placeholder search (`q`: \"\")\n - note: The ranking rules of query #0 were modified during canonicalization:\n 1. Removed relevancy rule `words` at position #1 in ranking rules because the query is a placeholder search (`q`: \"\")\n 2. Removed relevancy rule `typo` at position #2 in ranking rules because the query is a placeholder search (`q`: \"\")\n 3. Removed relevancy rule `proximity` at position #3 in ranking rules because the query is a placeholder search (`q`: \"\")\n 4. Removed relevancy rule `attribute` at position #4 in ranking rules because the query is a placeholder search (`q`: \"\")\n 5. Removed relevancy rule `exactness` at position #5 in ranking rules because the query is a placeholder search (`q`: \"\")\n", + "code": "invalid_multi_search_query_ranking_rules", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_multi_search_query_ranking_rules" + } + "###); + + // mix and match query: more or less by ranking score + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "batman", "q": "badman returns", "sort": ["id:desc"], "showRankingScore": true }, + {"indexUid" : "movies", "q": "captain", "sort": ["title:asc"], "showRankingScore": true }, + {"indexUid" : "batman", "q": "the bat", "sort": ["id:desc"], "showRankingScore": true }, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "message": "Inside `.queries[1]`: The results of queries #2 and #1 are incompatible: \n 1. `queries[2].sort[0]`, `batman.rankingRules[0]`: descending sort rule(s) on field `id`\n 2. `queries[1].sort[0]`, `movies.rankingRules[0]`: ascending sort rule(s) on field `title`\n - cannot compare two sort rules in opposite directions\n", + "code": "invalid_multi_search_query_ranking_rules", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_multi_search_query_ranking_rules" + } + "###); +} + +#[actix_rt::test] +async fn federation_limit_offset() { + let server = Server::new().await; + let index = server.index("test"); + + let documents = DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(0).await; + + let index = server.index("nested"); + let documents = NESTED_DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(1).await; + + let index = server.index("score"); + let documents = SCORE_DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(2).await; + { + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "test", "q": "glass", "attributesToRetrieve": ["title"]}, + {"indexUid" : "test", "q": "captain", "attributesToRetrieve": ["title"]}, + {"indexUid": "nested", "q": "pésti", "attributesToRetrieve": ["id"]}, + {"indexUid" : "test", "q": "Escape", "attributesToRetrieve": ["title"]}, + {"indexUid": "nested", "q": "jean", "attributesToRetrieve": ["id"]}, + {"indexUid": "score", "q": "jean", "attributesToRetrieve": ["title"]}, + {"indexUid": "test", "q": "the bat", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "the bat", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "badman returns", "attributesToRetrieve": ["title"]}, + {"indexUid" : "score", "q": "batman", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "batman returns", "attributesToRetrieve": ["title"]}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]", ".**._rankingScore" => "[score]" }, @r###" + { + "hits": [ + { + "title": "Gläss", + "_federation": { + "indexUid": "test", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + } + }, + { + "id": 852, + "_federation": { + "indexUid": "nested", + "queriesPosition": 2, + "weightedRankingScore": 1.0 + } + }, + { + "title": "Batman", + "_federation": { + "indexUid": "score", + "queriesPosition": 9, + "weightedRankingScore": 1.0 + } + }, + { + "title": "Batman Returns", + "_federation": { + "indexUid": "score", + "queriesPosition": 10, + "weightedRankingScore": 1.0 + } + }, + { + "title": "Captain Marvel", + "_federation": { + "indexUid": "test", + "queriesPosition": 1, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "title": "Escape Room", + "_federation": { + "indexUid": "test", + "queriesPosition": 3, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "id": 951, + "_federation": { + "indexUid": "nested", + "queriesPosition": 4, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "title": "Batman the dark knight returns: Part 1", + "_federation": { + "indexUid": "score", + "queriesPosition": 9, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "title": "Batman the dark knight returns: Part 2", + "_federation": { + "indexUid": "score", + "queriesPosition": 9, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "id": 654, + "_federation": { + "indexUid": "nested", + "queriesPosition": 2, + "weightedRankingScore": 0.7803030303030303 + } + }, + { + "title": "Badman", + "_federation": { + "indexUid": "score", + "queriesPosition": 8, + "weightedRankingScore": 0.5 + } + }, + { + "title": "How to Train Your Dragon: The Hidden World", + "_federation": { + "indexUid": "test", + "queriesPosition": 6, + "weightedRankingScore": 0.4166666666666667 + } + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 12 + } + "###); + } + + { + let (response, code) = server + .multi_search(json!({"federation": {"limit": 1}, "queries": [ + {"indexUid" : "test", "q": "glass", "attributesToRetrieve": ["title"]}, + {"indexUid" : "test", "q": "captain", "attributesToRetrieve": ["title"]}, + {"indexUid": "nested", "q": "pésti", "attributesToRetrieve": ["id"]}, + {"indexUid" : "test", "q": "Escape", "attributesToRetrieve": ["title"]}, + {"indexUid": "nested", "q": "jean", "attributesToRetrieve": ["id"]}, + {"indexUid": "score", "q": "jean", "attributesToRetrieve": ["title"]}, + {"indexUid": "test", "q": "the bat", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "the bat", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "badman returns", "attributesToRetrieve": ["title"]}, + {"indexUid" : "score", "q": "batman", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "batman returns", "attributesToRetrieve": ["title"]}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]", ".**._rankingScore" => "[score]" }, @r###" + { + "hits": [ + { + "title": "Gläss", + "_federation": { + "indexUid": "test", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + } + } + ], + "processingTimeMs": "[time]", + "limit": 1, + "offset": 0, + "estimatedTotalHits": 12 + } + "###); + } + + { + let (response, code) = server + .multi_search(json!({"federation": {"offset": 2}, "queries": [ + {"indexUid" : "test", "q": "glass", "attributesToRetrieve": ["title"]}, + {"indexUid" : "test", "q": "captain", "attributesToRetrieve": ["title"]}, + {"indexUid": "nested", "q": "pésti", "attributesToRetrieve": ["id"]}, + {"indexUid" : "test", "q": "Escape", "attributesToRetrieve": ["title"]}, + {"indexUid": "nested", "q": "jean", "attributesToRetrieve": ["id"]}, + {"indexUid": "score", "q": "jean", "attributesToRetrieve": ["title"]}, + {"indexUid": "test", "q": "the bat", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "the bat", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "badman returns", "attributesToRetrieve": ["title"]}, + {"indexUid" : "score", "q": "batman", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "batman returns", "attributesToRetrieve": ["title"]}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]", ".**._rankingScore" => "[score]" }, @r###" + { + "hits": [ + { + "title": "Batman", + "_federation": { + "indexUid": "score", + "queriesPosition": 9, + "weightedRankingScore": 1.0 + } + }, + { + "title": "Batman Returns", + "_federation": { + "indexUid": "score", + "queriesPosition": 10, + "weightedRankingScore": 1.0 + } + }, + { + "title": "Captain Marvel", + "_federation": { + "indexUid": "test", + "queriesPosition": 1, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "title": "Escape Room", + "_federation": { + "indexUid": "test", + "queriesPosition": 3, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "id": 951, + "_federation": { + "indexUid": "nested", + "queriesPosition": 4, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "title": "Batman the dark knight returns: Part 1", + "_federation": { + "indexUid": "score", + "queriesPosition": 9, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "title": "Batman the dark knight returns: Part 2", + "_federation": { + "indexUid": "score", + "queriesPosition": 9, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "id": 654, + "_federation": { + "indexUid": "nested", + "queriesPosition": 2, + "weightedRankingScore": 0.7803030303030303 + } + }, + { + "title": "Badman", + "_federation": { + "indexUid": "score", + "queriesPosition": 8, + "weightedRankingScore": 0.5 + } + }, + { + "title": "How to Train Your Dragon: The Hidden World", + "_federation": { + "indexUid": "test", + "queriesPosition": 6, + "weightedRankingScore": 0.4166666666666667 + } + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 2, + "estimatedTotalHits": 12 + } + "###); + } + + { + let (response, code) = server + .multi_search(json!({"federation": {"offset": 12}, "queries": [ + {"indexUid" : "test", "q": "glass", "attributesToRetrieve": ["title"]}, + {"indexUid" : "test", "q": "captain", "attributesToRetrieve": ["title"]}, + {"indexUid": "nested", "q": "pésti", "attributesToRetrieve": ["id"]}, + {"indexUid" : "test", "q": "Escape", "attributesToRetrieve": ["title"]}, + {"indexUid": "nested", "q": "jean", "attributesToRetrieve": ["id"]}, + {"indexUid": "score", "q": "jean", "attributesToRetrieve": ["title"]}, + {"indexUid": "test", "q": "the bat", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "the bat", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "badman returns", "attributesToRetrieve": ["title"]}, + {"indexUid" : "score", "q": "batman", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "batman returns", "attributesToRetrieve": ["title"]}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]", ".**._rankingScore" => "[score]" }, @r###" + { + "hits": [], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 12, + "estimatedTotalHits": 12 + } + "###); + } +} + +#[actix_rt::test] +async fn federation_formatting() { + let server = Server::new().await; + let index = server.index("test"); + + let documents = DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(0).await; + + let index = server.index("nested"); + let documents = NESTED_DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(1).await; + + let index = server.index("score"); + let documents = SCORE_DOCUMENTS.clone(); + index.add_documents(documents, None).await; + index.wait_task(2).await; + { + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "test", "q": "glass", "attributesToRetrieve": ["title"], "attributesToHighlight": ["title"]}, + {"indexUid" : "test", "q": "captain", "attributesToRetrieve": ["title"], "attributesToHighlight": ["title"]}, + {"indexUid": "nested", "q": "pésti", "attributesToRetrieve": ["id"]}, + {"indexUid" : "test", "q": "Escape", "attributesToRetrieve": ["title"], "attributesToHighlight": ["title"]}, + {"indexUid": "nested", "q": "jean", "attributesToRetrieve": ["id"]}, + {"indexUid": "score", "q": "jean", "attributesToRetrieve": ["title"], "attributesToHighlight": ["title"]}, + {"indexUid": "test", "q": "the bat", "attributesToRetrieve": ["title"], "attributesToHighlight": ["title"]}, + {"indexUid": "score", "q": "the bat", "attributesToRetrieve": ["title"], "attributesToHighlight": ["title"]}, + {"indexUid": "score", "q": "badman returns", "attributesToRetrieve": ["title"], "attributesToHighlight": ["title"]}, + {"indexUid" : "score", "q": "batman", "attributesToRetrieve": ["title"], "attributesToHighlight": ["title"]}, + {"indexUid": "score", "q": "batman returns", "attributesToRetrieve": ["title"], "attributesToHighlight": ["title"]}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]", ".**._rankingScore" => "[score]" }, @r###" + { + "hits": [ + { + "title": "Gläss", + "_federation": { + "indexUid": "test", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + }, + "_formatted": { + "title": "Gläss" + } + }, + { + "id": 852, + "_federation": { + "indexUid": "nested", + "queriesPosition": 2, + "weightedRankingScore": 1.0 + } + }, + { + "title": "Batman", + "_federation": { + "indexUid": "score", + "queriesPosition": 9, + "weightedRankingScore": 1.0 + }, + "_formatted": { + "title": "Batman" + } + }, + { + "title": "Batman Returns", + "_federation": { + "indexUid": "score", + "queriesPosition": 10, + "weightedRankingScore": 1.0 + }, + "_formatted": { + "title": "Batman Returns" + } + }, + { + "title": "Captain Marvel", + "_federation": { + "indexUid": "test", + "queriesPosition": 1, + "weightedRankingScore": 0.9848484848484848 + }, + "_formatted": { + "title": "Captain Marvel" + } + }, + { + "title": "Escape Room", + "_federation": { + "indexUid": "test", + "queriesPosition": 3, + "weightedRankingScore": 0.9848484848484848 + }, + "_formatted": { + "title": "Escape Room" + } + }, + { + "id": 951, + "_federation": { + "indexUid": "nested", + "queriesPosition": 4, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "title": "Batman the dark knight returns: Part 1", + "_federation": { + "indexUid": "score", + "queriesPosition": 9, + "weightedRankingScore": 0.9848484848484848 + }, + "_formatted": { + "title": "Batman the dark knight returns: Part 1" + } + }, + { + "title": "Batman the dark knight returns: Part 2", + "_federation": { + "indexUid": "score", + "queriesPosition": 9, + "weightedRankingScore": 0.9848484848484848 + }, + "_formatted": { + "title": "Batman the dark knight returns: Part 2" + } + }, + { + "id": 654, + "_federation": { + "indexUid": "nested", + "queriesPosition": 2, + "weightedRankingScore": 0.7803030303030303 + } + }, + { + "title": "Badman", + "_federation": { + "indexUid": "score", + "queriesPosition": 8, + "weightedRankingScore": 0.5 + }, + "_formatted": { + "title": "Badman" + } + }, + { + "title": "How to Train Your Dragon: The Hidden World", + "_federation": { + "indexUid": "test", + "queriesPosition": 6, + "weightedRankingScore": 0.4166666666666667 + }, + "_formatted": { + "title": "How to Train Your Dragon: The Hidden World" + } + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 12 + } + "###); + } + + { + let (response, code) = server + .multi_search(json!({"federation": {"limit": 1}, "queries": [ + {"indexUid" : "test", "q": "glass", "attributesToRetrieve": ["title"]}, + {"indexUid" : "test", "q": "captain", "attributesToRetrieve": ["title"]}, + {"indexUid": "nested", "q": "pésti", "attributesToRetrieve": ["id"]}, + {"indexUid" : "test", "q": "Escape", "attributesToRetrieve": ["title"]}, + {"indexUid": "nested", "q": "jean", "attributesToRetrieve": ["id"]}, + {"indexUid": "score", "q": "jean", "attributesToRetrieve": ["title"]}, + {"indexUid": "test", "q": "the bat", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "the bat", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "badman returns", "attributesToRetrieve": ["title"]}, + {"indexUid" : "score", "q": "batman", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "batman returns", "attributesToRetrieve": ["title"]}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]", ".**._rankingScore" => "[score]" }, @r###" + { + "hits": [ + { + "title": "Gläss", + "_federation": { + "indexUid": "test", + "queriesPosition": 0, + "weightedRankingScore": 1.0 + } + } + ], + "processingTimeMs": "[time]", + "limit": 1, + "offset": 0, + "estimatedTotalHits": 12 + } + "###); + } + + { + let (response, code) = server + .multi_search(json!({"federation": {"offset": 2}, "queries": [ + {"indexUid" : "test", "q": "glass", "attributesToRetrieve": ["title"]}, + {"indexUid" : "test", "q": "captain", "attributesToRetrieve": ["title"]}, + {"indexUid": "nested", "q": "pésti", "attributesToRetrieve": ["id"]}, + {"indexUid" : "test", "q": "Escape", "attributesToRetrieve": ["title"]}, + {"indexUid": "nested", "q": "jean", "attributesToRetrieve": ["id"]}, + {"indexUid": "score", "q": "jean", "attributesToRetrieve": ["title"]}, + {"indexUid": "test", "q": "the bat", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "the bat", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "badman returns", "attributesToRetrieve": ["title"]}, + {"indexUid" : "score", "q": "batman", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "batman returns", "attributesToRetrieve": ["title"]}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]", ".**._rankingScore" => "[score]" }, @r###" + { + "hits": [ + { + "title": "Batman", + "_federation": { + "indexUid": "score", + "queriesPosition": 9, + "weightedRankingScore": 1.0 + } + }, + { + "title": "Batman Returns", + "_federation": { + "indexUid": "score", + "queriesPosition": 10, + "weightedRankingScore": 1.0 + } + }, + { + "title": "Captain Marvel", + "_federation": { + "indexUid": "test", + "queriesPosition": 1, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "title": "Escape Room", + "_federation": { + "indexUid": "test", + "queriesPosition": 3, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "id": 951, + "_federation": { + "indexUid": "nested", + "queriesPosition": 4, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "title": "Batman the dark knight returns: Part 1", + "_federation": { + "indexUid": "score", + "queriesPosition": 9, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "title": "Batman the dark knight returns: Part 2", + "_federation": { + "indexUid": "score", + "queriesPosition": 9, + "weightedRankingScore": 0.9848484848484848 + } + }, + { + "id": 654, + "_federation": { + "indexUid": "nested", + "queriesPosition": 2, + "weightedRankingScore": 0.7803030303030303 + } + }, + { + "title": "Badman", + "_federation": { + "indexUid": "score", + "queriesPosition": 8, + "weightedRankingScore": 0.5 + } + }, + { + "title": "How to Train Your Dragon: The Hidden World", + "_federation": { + "indexUid": "test", + "queriesPosition": 6, + "weightedRankingScore": 0.4166666666666667 + } + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 2, + "estimatedTotalHits": 12 + } + "###); + } + + { + let (response, code) = server + .multi_search(json!({"federation": {"offset": 12}, "queries": [ + {"indexUid" : "test", "q": "glass", "attributesToRetrieve": ["title"]}, + {"indexUid" : "test", "q": "captain", "attributesToRetrieve": ["title"]}, + {"indexUid": "nested", "q": "pésti", "attributesToRetrieve": ["id"]}, + {"indexUid" : "test", "q": "Escape", "attributesToRetrieve": ["title"]}, + {"indexUid": "nested", "q": "jean", "attributesToRetrieve": ["id"]}, + {"indexUid": "score", "q": "jean", "attributesToRetrieve": ["title"]}, + {"indexUid": "test", "q": "the bat", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "the bat", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "badman returns", "attributesToRetrieve": ["title"]}, + {"indexUid" : "score", "q": "batman", "attributesToRetrieve": ["title"]}, + {"indexUid": "score", "q": "batman returns", "attributesToRetrieve": ["title"]}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]", ".**._rankingScore" => "[score]" }, @r###" + { + "hits": [], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 12, + "estimatedTotalHits": 12 + } + "###); + } +} + +#[actix_rt::test] +async fn federation_invalid_weight() { + let server = Server::new().await; + + let index = server.index("fruits"); + + let documents = FRUITS_DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + let (value, _) = index + .update_settings( + json!({"searchableAttributes": ["name"], "filterableAttributes": ["BOOST"]}), + ) + .await; + index.wait_task(value.uid()).await; + + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "fruits", "q": "apple red", "filter": "BOOST = true", "showRankingScore": true, "federationOptions": {"weight": 3.0}}, + {"indexUid": "fruits", "q": "apple red", "showRankingScore": true, "federationOptions": {"weight": -12}}, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "message": "Invalid value at `.queries[1].federationOptions.weight`: the value of `weight` is invalid, expected a positive float (>= 0.0).", + "code": "invalid_multi_search_weight", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_multi_search_weight" + } + "###); +} + +#[actix_rt::test] +async fn federation_null_weight() { + let server = Server::new().await; + + let index = server.index("fruits"); + + let documents = FRUITS_DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + let (value, _) = index + .update_settings( + json!({"searchableAttributes": ["name"], "filterableAttributes": ["BOOST"]}), + ) + .await; + index.wait_task(value.uid()).await; + + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "fruits", "q": "apple red", "filter": "BOOST = true", "showRankingScore": true, "federationOptions": {"weight": 3.0}}, + {"indexUid": "fruits", "q": "apple red", "showRankingScore": true, "federationOptions": {"weight": 0.0} }, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "hits": [ + { + "name": "Exclusive sale: Red delicious apple", + "id": "red-delicious-boosted", + "BOOST": true, + "_federation": { + "indexUid": "fruits", + "queriesPosition": 0, + "weightedRankingScore": 2.7281746031746033 + }, + "_rankingScore": 0.9093915343915344 + }, + { + "name": "Exclusive sale: green apple", + "id": "green-apple-boosted", + "BOOST": true, + "_federation": { + "indexUid": "fruits", + "queriesPosition": 0, + "weightedRankingScore": 1.318181818181818 + }, + "_rankingScore": 0.4393939393939394 + }, + { + "name": "Red apple gala", + "id": "red-apple-gala", + "_federation": { + "indexUid": "fruits", + "queriesPosition": 1, + "weightedRankingScore": 0.0 + }, + "_rankingScore": 0.953042328042328 + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 3 + } + "###); +} + +#[actix_rt::test] +async fn federation_federated_contains_pagination() { + let server = Server::new().await; + + let index = server.index("fruits"); + + let documents = FRUITS_DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + // fail when a federated query contains "limit" + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "fruits", "q": "apple red"}, + {"indexUid": "fruits", "q": "apple red", "limit": 5}, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "message": "Inside `.queries[1]`: Using pagination options is not allowed in federated queries.\n Hint: remove `limit` from query #1 or remove `federation: {}` from the request", + "code": "invalid_multi_search_query_pagination", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_multi_search_query_pagination" + } + "###); + // fail when a federated query contains "offset" + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "fruits", "q": "apple red"}, + {"indexUid": "fruits", "q": "apple red", "offset": 5}, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "message": "Inside `.queries[1]`: Using pagination options is not allowed in federated queries.\n Hint: remove `offset` from query #1 or remove `federation: {}` from the request", + "code": "invalid_multi_search_query_pagination", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_multi_search_query_pagination" + } + "###); + // fail when a federated query contains "page" + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "fruits", "q": "apple red"}, + {"indexUid": "fruits", "q": "apple red", "page": 2}, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "message": "Inside `.queries[1]`: Using pagination options is not allowed in federated queries.\n Hint: remove `page` from query #1 or remove `federation: {}` from the request", + "code": "invalid_multi_search_query_pagination", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_multi_search_query_pagination" + } + "###); + // fail when a federated query contains "hitsPerPage" + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "fruits", "q": "apple red"}, + {"indexUid": "fruits", "q": "apple red", "hitsPerPage": 5}, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "message": "Inside `.queries[1]`: Using pagination options is not allowed in federated queries.\n Hint: remove `hitsPerPage` from query #1 or remove `federation: {}` from the request", + "code": "invalid_multi_search_query_pagination", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_multi_search_query_pagination" + } + "###); +} + +#[actix_rt::test] +async fn federation_non_federated_contains_federation_option() { + let server = Server::new().await; + + let index = server.index("fruits"); + + let documents = FRUITS_DOCUMENTS.clone(); + let (value, _) = index.add_documents(documents, None).await; + index.wait_task(value.uid()).await; + + // fail when a non-federated query contains "federationOptions" + let (response, code) = server + .multi_search(json!({"queries": [ + {"indexUid" : "fruits", "q": "apple red"}, + {"indexUid": "fruits", "q": "apple red", "federationOptions": {}}, + ]})) + .await; + snapshot!(code, @"400 Bad Request"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]" }, @r###" + { + "message": "Inside `.queries[1]`: Using `federationOptions` is not allowed in a non-federated search.\n Hint: remove `federationOptions` from query #1 or add `federation: {}` to the request.", + "code": "invalid_multi_search_federation_options", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_multi_search_federation_options" + } + "###); +} + +#[actix_rt::test] +async fn federation_vector_single_index() { + let server = Server::new().await; + let (_, code) = server + .set_features(json!({ + "vectorStore": true + })) + .await; + + snapshot!(code, @"200 OK"); + + let index = server.index("vectors"); + + let (value, _) = index + .update_settings(json!({"embedders": { + "animal": { + "source": "userProvided", + "dimensions": 3 + }, + "sentiment": { + "source": "userProvided", + "dimensions": 2 + } + }})) + .await; + index.wait_task(value.uid()).await; + + let documents = VECTOR_DOCUMENTS.clone(); + let (value, code) = index.add_documents(documents, None).await; + snapshot!(code, @"202 Accepted"); + index.wait_task(value.uid()).await; + + // same embedder + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "vectors", "vector": [1.0, 0.0, 0.5], "hybrid": {"semanticRatio": 1.0, "embedder": "animal"}}, + {"indexUid": "vectors", "vector": [0.5, 0.5, 0.5], "hybrid": {"semanticRatio": 1.0, "embedder": "animal"}}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]", ".**._rankingScore" => "[score]" }, @r###" + { + "hits": [ + { + "id": "B", + "description": "the kitten scratched the beagle", + "_federation": { + "indexUid": "vectors", + "queriesPosition": 1, + "weightedRankingScore": 0.9870882034301758 + } + }, + { + "id": "D", + "description": "the little boy pets the puppy", + "_federation": { + "indexUid": "vectors", + "queriesPosition": 0, + "weightedRankingScore": 0.9728479385375975 + } + }, + { + "id": "C", + "description": "the dog had to stay alone today", + "_federation": { + "indexUid": "vectors", + "queriesPosition": 0, + "weightedRankingScore": 0.9701486229896544 + } + }, + { + "id": "A", + "description": "the dog barks at the cat", + "_federation": { + "indexUid": "vectors", + "queriesPosition": 1, + "weightedRankingScore": 0.9191691875457764 + } + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 4, + "semanticHitCount": 4 + } + "###); + + // distinct embedder + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "vectors", "vector": [1.0, 0.0, 0.5], "hybrid": {"semanticRatio": 1.0, "embedder": "animal"}}, + // joyful and energetic first + {"indexUid": "vectors", "vector": [0.8, 0.6], "hybrid": {"semanticRatio": 1.0, "embedder": "sentiment"}}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]", ".**._rankingScore" => "[score]" }, @r###" + { + "hits": [ + { + "id": "D", + "description": "the little boy pets the puppy", + "_federation": { + "indexUid": "vectors", + "queriesPosition": 1, + "weightedRankingScore": 0.979868710041046 + } + }, + { + "id": "C", + "description": "the dog had to stay alone today", + "_federation": { + "indexUid": "vectors", + "queriesPosition": 0, + "weightedRankingScore": 0.9701486229896544 + } + }, + { + "id": "B", + "description": "the kitten scratched the beagle", + "_federation": { + "indexUid": "vectors", + "queriesPosition": 0, + "weightedRankingScore": 0.8601469993591309 + } + }, + { + "id": "A", + "description": "the dog barks at the cat", + "_federation": { + "indexUid": "vectors", + "queriesPosition": 0, + "weightedRankingScore": 0.8432406187057495 + } + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 4, + "semanticHitCount": 4 + } + "###); + + // hybrid search, distinct embedder + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "vectors", "vector": [1.0, 0.0, 0.5], "hybrid": {"semanticRatio": 1.0, "embedder": "animal"}, "showRankingScore": true}, + // joyful and energetic first + {"indexUid": "vectors", "vector": [0.8, 0.6], "q": "beagle", "hybrid": {"semanticRatio": 1.0, "embedder": "sentiment"},"showRankingScore": true}, + {"indexUid": "vectors", "q": "dog", "showRankingScore": true}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]", ".**._rankingScore" => "[score]" }, @r###" + { + "hits": [ + { + "id": "D", + "description": "the little boy pets the puppy", + "_federation": { + "indexUid": "vectors", + "queriesPosition": 1, + "weightedRankingScore": 0.979868710041046 + }, + "_rankingScore": "[score]" + }, + { + "id": "C", + "description": "the dog had to stay alone today", + "_federation": { + "indexUid": "vectors", + "queriesPosition": 0, + "weightedRankingScore": 0.9701486229896544 + }, + "_rankingScore": "[score]" + }, + { + "id": "A", + "description": "the dog barks at the cat", + "_federation": { + "indexUid": "vectors", + "queriesPosition": 2, + "weightedRankingScore": 0.9242424242424242 + }, + "_rankingScore": "[score]" + }, + { + "id": "B", + "description": "the kitten scratched the beagle", + "_federation": { + "indexUid": "vectors", + "queriesPosition": 0, + "weightedRankingScore": 0.8601469993591309 + }, + "_rankingScore": "[score]" + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 4, + "semanticHitCount": 3 + } + "###); +} + +#[actix_rt::test] +async fn federation_vector_two_indexes() { + let server = Server::new().await; + let (_, code) = server + .set_features(json!({ + "vectorStore": true + })) + .await; + + snapshot!(code, @"200 OK"); + + let index = server.index("vectors-animal"); + + let (value, _) = index + .update_settings(json!({"embedders": { + "animal": { + "source": "userProvided", + "dimensions": 3 + }, + }})) + .await; + index.wait_task(value.uid()).await; + + let documents = VECTOR_DOCUMENTS.clone(); + let (value, code) = index.add_documents(documents, None).await; + snapshot!(code, @"202 Accepted"); + index.wait_task(value.uid()).await; + + let index = server.index("vectors-sentiment"); + + let (value, _) = index + .update_settings(json!({"embedders": { + "sentiment": { + "source": "userProvided", + "dimensions": 2 + } + }})) + .await; + index.wait_task(value.uid()).await; + + let documents = VECTOR_DOCUMENTS.clone(); + let (value, code) = index.add_documents(documents, None).await; + snapshot!(code, @"202 Accepted"); + index.wait_task(value.uid()).await; + + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "vectors-animal", "vector": [1.0, 0.0, 0.5], "hybrid": {"semanticRatio": 1.0, "embedder": "animal"}}, + // joyful and energetic first + {"indexUid": "vectors-sentiment", "vector": [0.8, 0.6], "hybrid": {"semanticRatio": 1.0, "embedder": "sentiment"}}, + {"indexUid": "vectors-sentiment", "q": "dog"}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]", ".**._rankingScore" => "[score]" }, @r###" + { + "hits": [ + { + "id": "D", + "description": "the little boy pets the puppy", + "_vectors": { + "animal": [ + 0.8, + 0.09, + 0.8 + ] + }, + "_federation": { + "indexUid": "vectors-sentiment", + "queriesPosition": 1, + "weightedRankingScore": 0.979868710041046 + } + }, + { + "id": "D", + "description": "the little boy pets the puppy", + "_vectors": { + "sentiment": [ + 0.8, + 0.3 + ] + }, + "_federation": { + "indexUid": "vectors-animal", + "queriesPosition": 0, + "weightedRankingScore": 0.9728479385375975 + } + }, + { + "id": "C", + "description": "the dog had to stay alone today", + "_vectors": { + "sentiment": [ + -1.0, + 0.1 + ] + }, + "_federation": { + "indexUid": "vectors-animal", + "queriesPosition": 0, + "weightedRankingScore": 0.9701486229896544 + } + }, + { + "id": "A", + "description": "the dog barks at the cat", + "_vectors": { + "animal": [ + 0.9, + 0.8, + 0.05 + ] + }, + "_federation": { + "indexUid": "vectors-sentiment", + "queriesPosition": 2, + "weightedRankingScore": 0.9242424242424242 + } + }, + { + "id": "C", + "description": "the dog had to stay alone today", + "_vectors": { + "animal": [ + 0.85, + 0.02, + 0.1 + ] + }, + "_federation": { + "indexUid": "vectors-sentiment", + "queriesPosition": 2, + "weightedRankingScore": 0.9242424242424242 + } + }, + { + "id": "B", + "description": "the kitten scratched the beagle", + "_vectors": { + "sentiment": [ + -0.2, + 0.65 + ] + }, + "_federation": { + "indexUid": "vectors-animal", + "queriesPosition": 0, + "weightedRankingScore": 0.8601469993591309 + } + }, + { + "id": "A", + "description": "the dog barks at the cat", + "_vectors": { + "sentiment": [ + -0.1, + 0.55 + ] + }, + "_federation": { + "indexUid": "vectors-animal", + "queriesPosition": 0, + "weightedRankingScore": 0.8432406187057495 + } + }, + { + "id": "B", + "description": "the kitten scratched the beagle", + "_vectors": { + "animal": [ + 0.8, + 0.9, + 0.5 + ] + }, + "_federation": { + "indexUid": "vectors-sentiment", + "queriesPosition": 1, + "weightedRankingScore": 0.6690993905067444 + } + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 8, + "semanticHitCount": 6 + } + "###); + + // hybrid search, distinct embedder + let (response, code) = server + .multi_search(json!({"federation": {}, "queries": [ + {"indexUid" : "vectors-animal", "vector": [1.0, 0.0, 0.5], "hybrid": {"semanticRatio": 1.0, "embedder": "animal"}, "showRankingScore": true}, + {"indexUid": "vectors-sentiment", "vector": [-1, 0.6], "q": "beagle", "hybrid": {"semanticRatio": 1.0, "embedder": "sentiment"}, "showRankingScore": true}, + ]})) + .await; + snapshot!(code, @"200 OK"); + insta::assert_json_snapshot!(response, { ".processingTimeMs" => "[time]", ".**._rankingScore" => "[score]" }, @r###" + { + "hits": [ + { + "id": "D", + "description": "the little boy pets the puppy", + "_vectors": { + "sentiment": [ + 0.8, + 0.3 + ] + }, + "_federation": { + "indexUid": "vectors-animal", + "queriesPosition": 0, + "weightedRankingScore": 0.9728479385375975 + }, + "_rankingScore": "[score]" + }, + { + "id": "C", + "description": "the dog had to stay alone today", + "_vectors": { + "sentiment": [ + -1.0, + 0.1 + ] + }, + "_federation": { + "indexUid": "vectors-animal", + "queriesPosition": 0, + "weightedRankingScore": 0.9701486229896544 + }, + "_rankingScore": "[score]" + }, + { + "id": "C", + "description": "the dog had to stay alone today", + "_vectors": { + "animal": [ + 0.85, + 0.02, + 0.1 + ] + }, + "_federation": { + "indexUid": "vectors-sentiment", + "queriesPosition": 1, + "weightedRankingScore": 0.9522157907485962 + }, + "_rankingScore": "[score]" + }, + { + "id": "B", + "description": "the kitten scratched the beagle", + "_vectors": { + "animal": [ + 0.8, + 0.9, + 0.5 + ] + }, + "_federation": { + "indexUid": "vectors-sentiment", + "queriesPosition": 1, + "weightedRankingScore": 0.8719604015350342 + }, + "_rankingScore": "[score]" + }, + { + "id": "B", + "description": "the kitten scratched the beagle", + "_vectors": { + "sentiment": [ + -0.2, + 0.65 + ] + }, + "_federation": { + "indexUid": "vectors-animal", + "queriesPosition": 0, + "weightedRankingScore": 0.8601469993591309 + }, + "_rankingScore": "[score]" + }, + { + "id": "A", + "description": "the dog barks at the cat", + "_vectors": { + "sentiment": [ + -0.1, + 0.55 + ] + }, + "_federation": { + "indexUid": "vectors-animal", + "queriesPosition": 0, + "weightedRankingScore": 0.8432406187057495 + }, + "_rankingScore": "[score]" + }, + { + "id": "A", + "description": "the dog barks at the cat", + "_vectors": { + "animal": [ + 0.9, + 0.8, + 0.05 + ] + }, + "_federation": { + "indexUid": "vectors-sentiment", + "queriesPosition": 1, + "weightedRankingScore": 0.8297949433326721 + }, + "_rankingScore": "[score]" + }, + { + "id": "D", + "description": "the little boy pets the puppy", + "_vectors": { + "animal": [ + 0.8, + 0.09, + 0.8 + ] + }, + "_federation": { + "indexUid": "vectors-sentiment", + "queriesPosition": 1, + "weightedRankingScore": 0.18887794017791748 + }, + "_rankingScore": "[score]" + } + ], + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 8, + "semanticHitCount": 8 + } + "###); +}