diff --git a/crates/meilisearch/tests/common/server.rs b/crates/meilisearch/tests/common/server.rs index f78542db1..d1e81e0a7 100644 --- a/crates/meilisearch/tests/common/server.rs +++ b/crates/meilisearch/tests/common/server.rs @@ -125,6 +125,12 @@ impl Server { self.service.post("/indexes", body).await } + pub async fn delete_index(&self, uid: impl AsRef) -> (Value, StatusCode) { + let url = format!("/indexes/{}", urlencoding::encode(uid.as_ref())); + let (value, code) = self.service.delete(url).await; + (value, code) + } + pub fn index_with_encoder(&self, uid: impl AsRef, encoder: Encoder) -> Index<'_> { Index { uid: uid.as_ref().to_string(), diff --git a/crates/meilisearch/tests/search/errors.rs b/crates/meilisearch/tests/search/errors.rs index c2014ca42..05f084a0e 100644 --- a/crates/meilisearch/tests/search/errors.rs +++ b/crates/meilisearch/tests/search/errors.rs @@ -646,14 +646,11 @@ async fn search_bad_matching_strategy() { #[actix_rt::test] async fn filter_invalid_syntax_object() { - let server = Server::new_shared(); - let index = server.unique_index(); - - let (task, _code) = index.update_settings(json!({"filterableAttributes": ["title"]})).await; - index.wait_task(task.uid()).await.succeeded(); - - index - .search(json!({"filter": "title & Glass"}), |response, code| { + test_settings_documents_indexing_swapping_and_search( + &DOCUMENTS, + &json!({"filterableAttributes": ["title"]}), + &json!({"filter": "title & Glass"}), + |response, code| { snapshot!(response, @r###" { "message": "Was expecting an operation `=`, `!=`, `>=`, `>`, `<=`, `<`, `IN`, `NOT IN`, `TO`, `EXISTS`, `NOT EXISTS`, `IS NULL`, `IS NOT NULL`, `IS EMPTY`, `IS NOT EMPTY`, `CONTAINS`, `NOT CONTAINS`, `STARTS WITH`, `NOT STARTS WITH`, `_geoRadius`, or `_geoBoundingBox` at `title & Glass`.\n1:14 title & Glass", @@ -663,20 +660,18 @@ async fn filter_invalid_syntax_object() { } "###); snapshot!(code, @"400 Bad Request"); - }) - .await; + }, + ) + .await; } #[actix_rt::test] async fn filter_invalid_syntax_array() { - let server = Server::new_shared(); - let index = server.unique_index(); - - let (task, _code) = index.update_settings(json!({"filterableAttributes": ["title"]})).await; - index.wait_task(task.uid()).await.succeeded(); - - index - .search(json!({"filter": ["title & Glass"]}), |response, code| { + test_settings_documents_indexing_swapping_and_search( + &DOCUMENTS, + &json!({"filterableAttributes": ["title"]}), + &json!({"filter": ["title & Glass"]}), + |response, code| { snapshot!(response, @r###" { "message": "Was expecting an operation `=`, `!=`, `>=`, `>`, `<=`, `<`, `IN`, `NOT IN`, `TO`, `EXISTS`, `NOT EXISTS`, `IS NULL`, `IS NOT NULL`, `IS EMPTY`, `IS NOT EMPTY`, `CONTAINS`, `NOT CONTAINS`, `STARTS WITH`, `NOT STARTS WITH`, `_geoRadius`, or `_geoBoundingBox` at `title & Glass`.\n1:14 title & Glass", @@ -686,206 +681,327 @@ async fn filter_invalid_syntax_array() { } "###); snapshot!(code, @"400 Bad Request"); - }) - .await; + }, + ) + .await; } #[actix_rt::test] async fn filter_invalid_syntax_string() { - let server = Server::new_shared(); - let index = server.unique_index(); - - let (task, _code) = index.update_settings(json!({"filterableAttributes": ["title"]})).await; - index.wait_task(task.uid()).await.succeeded(); - - let expected_response = json!({ - "message": "Found unexpected characters at the end of the filter: `XOR title = Glass`. You probably forgot an `OR` or an `AND` rule.\n15:32 title = Glass XOR title = Glass", - "code": "invalid_search_filter", - "type": "invalid_request", - "link": "https://docs.meilisearch.com/errors#invalid_search_filter" - }); - index - .search(json!({"filter": "title = Glass XOR title = Glass"}), |response, code| { - assert_eq!(response, expected_response); - assert_eq!(code, 400); - }) - .await; + test_settings_documents_indexing_swapping_and_search( + &DOCUMENTS, + &json!({"filterableAttributes": ["title"]}), + &json!({"filter": "title = Glass XOR title = Glass"}), + |response, code| { + snapshot!(response, @r###" + { + "message": "Found unexpected characters at the end of the filter: `XOR title = Glass`. You probably forgot an `OR` or an `AND` rule.\n15:32 title = Glass XOR title = Glass", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + snapshot!(code, @"400 Bad Request"); + }, + ) + .await; } #[actix_rt::test] async fn filter_invalid_attribute_array() { - let server = Server::new_shared(); - let index = server.unique_index(); - - let (task, _code) = index.update_settings(json!({"filterableAttributes": ["title"]})).await; - index.wait_task(task.uid()).await.succeeded(); - - let expected_response = json!({ - "message": format!("Index `{}`: Attribute `many` is not filterable. Available filterable attributes are: `title`.\n1:5 many = Glass", index.uid), - "code": "invalid_search_filter", - "type": "invalid_request", - "link": "https://docs.meilisearch.com/errors#invalid_search_filter" - }); - index - .search(json!({"filter": ["many = Glass"]}), |response, code| { - assert_eq!(response, expected_response); - assert_eq!(code, 400); - }) - .await; + test_settings_documents_indexing_swapping_and_search( + &DOCUMENTS, + &json!({"filterableAttributes": ["title"]}), + &json!({"filter": ["many = Glass"]}), + |response, code| { + snapshot!(response, @r###" + { + "message": "Index `test`: Attribute `many` is not filterable. Available filterable attributes are: `title`.\n1:5 many = Glass", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + snapshot!(code, @"400 Bad Request"); + }, + ) + .await; } #[actix_rt::test] async fn filter_invalid_attribute_string() { - let server = Server::new_shared(); - let index = server.unique_index(); - - let (task, _code) = index.update_settings(json!({"filterableAttributes": ["title"]})).await; - index.wait_task(task.uid()).await.succeeded(); - - let expected_response = json!({ - "message": format!("Index `{}`: Attribute `many` is not filterable. Available filterable attributes are: `title`.\n1:5 many = Glass", index.uid), - "code": "invalid_search_filter", - "type": "invalid_request", - "link": "https://docs.meilisearch.com/errors#invalid_search_filter" - }); - index - .search(json!({"filter": "many = Glass"}), |response, code| { - assert_eq!(response, expected_response); - assert_eq!(code, 400); - }) - .await; + test_settings_documents_indexing_swapping_and_search( + &DOCUMENTS, + &json!({"filterableAttributes": ["title"]}), + &json!({"filter": "many = Glass"}), + |response, code| { + snapshot!(response, @r###" + { + "message": "Index `test`: Attribute `many` is not filterable. Available filterable attributes are: `title`.\n1:5 many = Glass", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + snapshot!(code, @"400 Bad Request"); + }, + ) + .await; } #[actix_rt::test] async fn filter_reserved_geo_attribute_array() { - let server = Server::new_shared(); - let index = server.unique_index(); - - let (task, _code) = index.update_settings(json!({"filterableAttributes": ["title"]})).await; - index.wait_task(task.uid()).await.succeeded(); - - let expected_response = json!({ - "message": "`_geo` is a reserved keyword and thus can't be used as a filter expression. Use the `_geoRadius(latitude, longitude, distance)` or `_geoBoundingBox([latitude, longitude], [latitude, longitude])` built-in rules to filter on `_geo` coordinates.\n1:13 _geo = Glass", - "code": "invalid_search_filter", - "type": "invalid_request", - "link": "https://docs.meilisearch.com/errors#invalid_search_filter" - }); - index - .search(json!({"filter": ["_geo = Glass"]}), |response, code| { - assert_eq!(response, expected_response); - assert_eq!(code, 400); - }) - .await; + test_settings_documents_indexing_swapping_and_search( + &DOCUMENTS, + &json!({"filterableAttributes": ["title"]}), + &json!({"filter": ["_geo = Glass"]}), + |response, code| { + snapshot!(response, @r###" + { + "message": "`_geo` is a reserved keyword and thus can't be used as a filter expression. Use the `_geoRadius(latitude, longitude, distance)` or `_geoBoundingBox([latitude, longitude], [latitude, longitude])` built-in rules to filter on `_geo` coordinates.\n1:13 _geo = Glass", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + snapshot!(code, @"400 Bad Request"); + }, + ) + .await; } #[actix_rt::test] async fn filter_reserved_geo_attribute_string() { - let server = Server::new_shared(); - let index = server.unique_index(); - - let (task, _code) = index.update_settings(json!({"filterableAttributes": ["title"]})).await; - index.wait_task(task.uid()).await.succeeded(); - - let expected_response = json!({ - "message": "`_geo` is a reserved keyword and thus can't be used as a filter expression. Use the `_geoRadius(latitude, longitude, distance)` or `_geoBoundingBox([latitude, longitude], [latitude, longitude])` built-in rules to filter on `_geo` coordinates.\n1:13 _geo = Glass", - "code": "invalid_search_filter", - "type": "invalid_request", - "link": "https://docs.meilisearch.com/errors#invalid_search_filter" - }); - index - .search(json!({"filter": "_geo = Glass"}), |response, code| { - assert_eq!(response, expected_response); - assert_eq!(code, 400); - }) - .await; + test_settings_documents_indexing_swapping_and_search( + &DOCUMENTS, + &json!({"filterableAttributes": ["title"]}), + &json!({"filter": "_geo = Glass"}), + |response, code| { + snapshot!(response, @r###" + { + "message": "`_geo` is a reserved keyword and thus can't be used as a filter expression. Use the `_geoRadius(latitude, longitude, distance)` or `_geoBoundingBox([latitude, longitude], [latitude, longitude])` built-in rules to filter on `_geo` coordinates.\n1:13 _geo = Glass", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + snapshot!(code, @"400 Bad Request"); + }, + ) + .await; } #[actix_rt::test] async fn filter_reserved_attribute_array() { - let server = Server::new_shared(); - let index = server.unique_index(); - - let (task, _code) = index.update_settings(json!({"filterableAttributes": ["title"]})).await; - index.wait_task(task.uid()).await.succeeded(); - - let expected_response = json!({ - "message": "`_geoDistance` is a reserved keyword and thus can't be used as a filter expression. Use the `_geoRadius(latitude, longitude, distance)` or `_geoBoundingBox([latitude, longitude], [latitude, longitude])` built-in rules to filter on `_geo` coordinates.\n1:21 _geoDistance = Glass", - "code": "invalid_search_filter", - "type": "invalid_request", - "link": "https://docs.meilisearch.com/errors#invalid_search_filter" - }); - index - .search(json!({"filter": ["_geoDistance = Glass"]}), |response, code| { - assert_eq!(response, expected_response); - assert_eq!(code, 400); - }) - .await; + test_settings_documents_indexing_swapping_and_search( + &DOCUMENTS, + &json!({"filterableAttributes": ["title"]}), + &json!({"filter": ["_geoDistance = Glass"]}), + |response, code| { + snapshot!(response, @r###" + { + "message": "`_geoDistance` is a reserved keyword and thus can't be used as a filter expression. Use the `_geoRadius(latitude, longitude, distance)` or `_geoBoundingBox([latitude, longitude], [latitude, longitude])` built-in rules to filter on `_geo` coordinates.\n1:21 _geoDistance = Glass", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + snapshot!(code, @"400 Bad Request"); + }, + ) + .await; } #[actix_rt::test] async fn filter_reserved_attribute_string() { - let server = Server::new_shared(); - let index = server.unique_index(); - - let (task, _code) = index.update_settings(json!({"filterableAttributes": ["title"]})).await; - index.wait_task(task.uid()).await.succeeded(); - - let expected_response = json!({ - "message": "`_geoDistance` is a reserved keyword and thus can't be used as a filter expression. Use the `_geoRadius(latitude, longitude, distance)` or `_geoBoundingBox([latitude, longitude], [latitude, longitude])` built-in rules to filter on `_geo` coordinates.\n1:21 _geoDistance = Glass", - "code": "invalid_search_filter", - "type": "invalid_request", - "link": "https://docs.meilisearch.com/errors#invalid_search_filter" - }); - index - .search(json!({"filter": "_geoDistance = Glass"}), |response, code| { - assert_eq!(response, expected_response); - assert_eq!(code, 400); - }) - .await; + test_settings_documents_indexing_swapping_and_search( + &DOCUMENTS, + &json!({"filterableAttributes": ["title"]}), + &json!({"filter": "_geoDistance = Glass"}), + |response, code| { + snapshot!(response, @r###" + { + "message": "`_geoDistance` is a reserved keyword and thus can't be used as a filter expression. Use the `_geoRadius(latitude, longitude, distance)` or `_geoBoundingBox([latitude, longitude], [latitude, longitude])` built-in rules to filter on `_geo` coordinates.\n1:21 _geoDistance = Glass", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + snapshot!(code, @"400 Bad Request"); + }, + ) + .await; } #[actix_rt::test] async fn filter_reserved_geo_point_array() { - let server = Server::new_shared(); - let index = server.unique_index(); - - let (task, _code) = index.update_settings(json!({"filterableAttributes": ["title"]})).await; - index.wait_task(task.uid()).await.succeeded(); - - let expected_response = json!({ - "message": "`_geoPoint` is a reserved keyword and thus can't be used as a filter expression. Use the `_geoRadius(latitude, longitude, distance)` or `_geoBoundingBox([latitude, longitude], [latitude, longitude])` built-in rules to filter on `_geo` coordinates.\n1:18 _geoPoint = Glass", - "code": "invalid_search_filter", - "type": "invalid_request", - "link": "https://docs.meilisearch.com/errors#invalid_search_filter" - }); - index - .search(json!({"filter": ["_geoPoint = Glass"]}), |response, code| { - assert_eq!(response, expected_response); - assert_eq!(code, 400); - }) - .await; + test_settings_documents_indexing_swapping_and_search( + &DOCUMENTS, + &json!({"filterableAttributes": ["title"]}), + &json!({"filter": ["_geoPoint = Glass"]}), + |response, code| { + snapshot!(response, @r###" + { + "message": "`_geoPoint` is a reserved keyword and thus can't be used as a filter expression. Use the `_geoRadius(latitude, longitude, distance)` or `_geoBoundingBox([latitude, longitude], [latitude, longitude])` built-in rules to filter on `_geo` coordinates.\n1:18 _geoPoint = Glass", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + snapshot!(code, @"400 Bad Request"); + }, + ) + .await; } #[actix_rt::test] async fn filter_reserved_geo_point_string() { - let server = Server::new_shared(); - let index = server.unique_index(); + test_settings_documents_indexing_swapping_and_search( + &DOCUMENTS, + &json!({"filterableAttributes": ["title"]}), + &json!({"filter": "_geoPoint = Glass"}), + |response, code| { + snapshot!(response, @r###" + { + "message": "`_geoPoint` is a reserved keyword and thus can't be used as a filter expression. Use the `_geoRadius(latitude, longitude, distance)` or `_geoBoundingBox([latitude, longitude], [latitude, longitude])` built-in rules to filter on `_geo` coordinates.\n1:18 _geoPoint = Glass", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + snapshot!(code, @"400 Bad Request"); + }, + ) + .await; +} - let (task, _code) = index.update_settings(json!({"filterableAttributes": ["title"]})).await; - index.wait_task(task.uid()).await.succeeded(); +#[actix_rt::test] +async fn search_with_pattern_filter_settings_errors() { + // Check if the Equality filter works with patterns + test_settings_documents_indexing_swapping_and_search( + &NESTED_DOCUMENTS, + &json!({"filterableAttributes": [{ + "patterns": ["cattos","doggos.age"], + "features": { + "facetSearch": false, + "filter": {"equality": false, "comparison": true} + } + }]}), + &json!({ + "filter": "cattos = pésti" + }), + |response, code| { + snapshot!(code, @"400 Bad Request"); + snapshot!(json_string!(response), @r###" + { + "message": "Index `test`: Filter operator `=` is not allowed for the attribute `cattos`, allowed operators: OR, AND, NOT, <, >, <=, >=, TO, IS EMPTY, IS NULL, EXISTS.", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + }, + ) + .await; - let expected_response = json!({ - "message": "`_geoPoint` is a reserved keyword and thus can't be used as a filter expression. Use the `_geoRadius(latitude, longitude, distance)` or `_geoBoundingBox([latitude, longitude], [latitude, longitude])` built-in rules to filter on `_geo` coordinates.\n1:18 _geoPoint = Glass", - "code": "invalid_search_filter", - "type": "invalid_request", - "link": "https://docs.meilisearch.com/errors#invalid_search_filter" - }); - index - .search(json!({"filter": "_geoPoint = Glass"}), |response, code| { - assert_eq!(response, expected_response); - assert_eq!(code, 400); - }) - .await; + test_settings_documents_indexing_swapping_and_search( + &NESTED_DOCUMENTS, + &json!({"filterableAttributes": [{ + "patterns": ["cattos","doggos.age"], + "features": { + "facetSearch": false, + "filter": {"equality": false, "comparison": true} + } + }]}), + &json!({ + "filter": "cattos IN [pésti, simba]" + }), + |response, code| { + snapshot!(code, @"400 Bad Request"); + snapshot!(json_string!(response), @r###" + { + "message": "Index `test`: Filter operator `=` is not allowed for the attribute `cattos`, allowed operators: OR, AND, NOT, <, >, <=, >=, TO, IS EMPTY, IS NULL, EXISTS.", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + }, +) +.await; + + // Check if the Comparison filter works with patterns + test_settings_documents_indexing_swapping_and_search( + &NESTED_DOCUMENTS, + &json!({"filterableAttributes": [{"patterns": ["cattos","doggos.age"]}]}), + &json!({ + "filter": "doggos.age > 2" + }), + |response, code| { + snapshot!(code, @"400 Bad Request"); + snapshot!(json_string!(response), @r###" + { + "message": "Index `test`: Filter operator `>` is not allowed for the attribute `doggos.age`, allowed operators: OR, AND, NOT, =, !=, IN, IS EMPTY, IS NULL, EXISTS.", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + }, + ) + .await; + + test_settings_documents_indexing_swapping_and_search( + &NESTED_DOCUMENTS, + &json!({"filterableAttributes": [{ + "patterns": ["cattos","doggos.age"], + "features": { + "facetSearch": false, + "filter": {"equality": true, "comparison": false} + } + }]}), + &json!({ + "filter": "doggos.age > 2" + }), + |response, code| { + snapshot!(code, @"400 Bad Request"); + snapshot!(json_string!(response), @r###" + { + "message": "Index `test`: Filter operator `>` is not allowed for the attribute `doggos.age`, allowed operators: OR, AND, NOT, =, !=, IN, IS EMPTY, IS NULL, EXISTS.", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + }, + ) + .await; + + test_settings_documents_indexing_swapping_and_search( + &NESTED_DOCUMENTS, + &json!({"filterableAttributes": [{ + "patterns": ["cattos","doggos.age"], + "features": { + "facetSearch": false, + "filter": {"equality": true, "comparison": false} + } + }]}), + &json!({ + "filter": "doggos.age 2 TO 4" + }), + |response, code| { + snapshot!(code, @"400 Bad Request"); + snapshot!(json_string!(response), @r###" + { + "message": "Index `test`: Filter operator `TO` is not allowed for the attribute `doggos.age`, allowed operators: OR, AND, NOT, =, !=, IN, IS EMPTY, IS NULL, EXISTS.", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + }, + ) + .await; } #[actix_rt::test] @@ -1028,62 +1144,62 @@ async fn sort_unset_ranking_rule() { #[actix_rt::test] async fn search_on_unknown_field() { - let server = Server::new_shared(); - let index = server.unique_index(); - let (response, _code) = - index.update_settings_searchable_attributes(json!(["id", "title"])).await; - index.wait_task(response.uid()).await.succeeded(); - - let expected_response = json!({ - "message": format!("Index `{}`: Attribute `unknown` is not searchable. Available searchable attributes are: `id, title`.", index.uid), - "code": "invalid_search_attributes_to_search_on", - "type": "invalid_request", - "link": "https://docs.meilisearch.com/errors#invalid_search_attributes_to_search_on" - }); - index - .search( - json!({"q": "Captain Marvel", "attributesToSearchOn": ["unknown"]}), - |response, code| { - assert_eq!(response, expected_response); - assert_eq!(code, 400); - }, - ) - .await; + test_settings_documents_indexing_swapping_and_search( + &DOCUMENTS, + &json!({"searchableAttributes": ["id", "title"]}), + &json!({"q": "Captain Marvel", "attributesToSearchOn": ["unknown"]}), + |response, code| { + snapshot!(code, @"400 Bad Request"); + snapshot!(response, @r###" + { + "message": "Index `test`: Attribute `unknown` is not searchable. Available searchable attributes are: `id, title`.", + "code": "invalid_search_attributes_to_search_on", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_attributes_to_search_on" + } + "###); + }, + ) + .await; } #[actix_rt::test] async fn search_on_unknown_field_plus_joker() { - let server = Server::new_shared(); - let index = server.unique_index(); - let (response, _code) = - index.update_settings_searchable_attributes(json!(["id", "title"])).await; - index.wait_task(response.uid()).await.succeeded(); + test_settings_documents_indexing_swapping_and_search( + &DOCUMENTS, + &json!({"searchableAttributes": ["id", "title"]}), + &json!({"q": "Captain Marvel", "attributesToSearchOn": ["*", "unknown"]}), + |response, code| { + snapshot!(code, @"400 Bad Request"); + snapshot!(response, @r###" + { + "message": "Index `test`: Attribute `unknown` is not searchable. Available searchable attributes are: `id, title`.", + "code": "invalid_search_attributes_to_search_on", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_attributes_to_search_on" + } + "###); + }, + ) + .await; - let expected_response = json!({ - "message": format!("Index `{}`: Attribute `unknown` is not searchable. Available searchable attributes are: `id, title`.", index.uid), - "code": "invalid_search_attributes_to_search_on", - "type": "invalid_request", - "link": "https://docs.meilisearch.com/errors#invalid_search_attributes_to_search_on" - }); - index - .search( - json!({"q": "Captain Marvel", "attributesToSearchOn": ["*", "unknown"]}), - |response, code| { - assert_eq!(response, expected_response); - assert_eq!(code, 400); - }, - ) - .await; - - index - .search( - json!({"q": "Captain Marvel", "attributesToSearchOn": ["unknown", "*"]}), - |response, code| { - assert_eq!(response, expected_response); - assert_eq!(code, 400); - }, - ) - .await; + test_settings_documents_indexing_swapping_and_search( + &DOCUMENTS, + &json!({"searchableAttributes": ["id", "title"]}), + &json!({"q": "Captain Marvel", "attributesToSearchOn": ["unknown", "*"]}), + |response, code| { + snapshot!(code, @"400 Bad Request"); + snapshot!(response, @r###" + { + "message": "Index `test`: Attribute `unknown` is not searchable. Available searchable attributes are: `id, title`.", + "code": "invalid_search_attributes_to_search_on", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_attributes_to_search_on" + } + "###); + }, + ) + .await; } #[actix_rt::test] @@ -1092,6 +1208,9 @@ async fn distinct_at_search_time() { let index = server.unique_index(); let (task, _) = index.create(None).await; index.wait_task(task.uid()).await.succeeded(); + let (response, _code) = + index.add_documents(json!([{"id": 1, "color": "Doggo", "machin": "Action"}]), None).await; + index.wait_task(response.uid()).await.succeeded(); let expected_response = json!({ "message": format!("Index `{}`: Attribute `doggo.truc` is not filterable and thus, cannot be used as distinct attribute. This index does not have configured filterable attributes.", index.uid), diff --git a/crates/meilisearch/tests/search/facet_search.rs b/crates/meilisearch/tests/search/facet_search.rs index 7e46c5d15..33b906d0f 100644 --- a/crates/meilisearch/tests/search/facet_search.rs +++ b/crates/meilisearch/tests/search/facet_search.rs @@ -1,7 +1,9 @@ use meili_snap::snapshot; +use meilisearch::Opt; use once_cell::sync::Lazy; +use tempfile::TempDir; -use crate::common::{Server, Value}; +use crate::common::{default_settings, Server, Value, NESTED_DOCUMENTS}; use crate::json; static DOCUMENTS: Lazy = Lazy::new(|| { @@ -34,6 +36,62 @@ static DOCUMENTS: Lazy = Lazy::new(|| { ]) }); +async fn test_settings_documents_indexing_swapping_and_facet_search( + documents: &Value, + settings: &Value, + query: &Value, + test: impl Fn(Value, actix_http::StatusCode) + std::panic::UnwindSafe + Clone, +) { + let temp = TempDir::new().unwrap(); + let server = Server::new_with_options(Opt { ..default_settings(temp.path()) }).await.unwrap(); + + eprintln!("Documents -> Settings -> test"); + let index = server.index("test"); + + let (task, code) = index.add_documents(documents.clone(), None).await; + assert_eq!(code, 202, "{}", task); + let response = index.wait_task(task.uid()).await; + assert!(response.is_success(), "{:?}", response); + + let (task, code) = index.update_settings(settings.clone()).await; + assert_eq!(code, 202, "{}", task); + let response = index.wait_task(task.uid()).await; + assert!(response.is_success(), "{:?}", response); + + let (response, code) = index.facet_search(query.clone()).await; + insta::allow_duplicates! { + test(response, code); + } + + let (task, code) = server.delete_index("test").await; + assert_eq!(code, 202, "{}", task); + let response = server.wait_task(task.uid()).await; + assert!(response.is_success(), "{:?}", response); + + eprintln!("Settings -> Documents -> test"); + let index = server.index("test"); + + let (task, code) = index.update_settings(settings.clone()).await; + assert_eq!(code, 202, "{}", task); + let response = index.wait_task(task.uid()).await; + assert!(response.is_success(), "{:?}", response); + + let (task, code) = index.add_documents(documents.clone(), None).await; + assert_eq!(code, 202, "{}", task); + let response = index.wait_task(task.uid()).await; + assert!(response.is_success(), "{:?}", response); + + let (response, code) = index.facet_search(query.clone()).await; + insta::allow_duplicates! { + test(response, code); + } + + let (task, code) = server.delete_index("test").await; + assert_eq!(code, 202, "{}", task); + let response = server.wait_task(task.uid()).await; + assert!(response.is_success(), "{:?}", response); +} + #[actix_rt::test] async fn simple_facet_search() { let server = Server::new().await; @@ -436,3 +494,124 @@ async fn deactivate_facet_search_add_documents_and_reset_facet_search() { assert_eq!(code, 200, "{}", response); assert_eq!(dbg!(response)["facetHits"].as_array().unwrap().len(), 2); } + +#[actix_rt::test] +async fn facet_search_with_filterable_attributes_rules() { + test_settings_documents_indexing_swapping_and_facet_search( + &DOCUMENTS, + &json!({"filterableAttributes": ["genres"]}), + &json!({"facetName": "genres", "facetQuery": "a"}), + |response, code| { + snapshot!(code, @"200 OK"); + snapshot!(response["facetHits"], @r###"[{"value":"Action","count":3},{"value":"Adventure","count":2}]"###); + }, + ) + .await; + + test_settings_documents_indexing_swapping_and_facet_search( + &DOCUMENTS, + &json!({"filterableAttributes": [{"patterns": ["genres"], "features": {"facetSearch": true, "filter": {"equality": false, "comparison": false}}}]}), + &json!({"facetName": "genres", "facetQuery": "a"}), + |response, code| { + snapshot!(code, @"200 OK"); + snapshot!(response["facetHits"], @r###"[{"value":"Action","count":3},{"value":"Adventure","count":2}]"###); + }, + ).await; + + test_settings_documents_indexing_swapping_and_facet_search( + &NESTED_DOCUMENTS, + &json!({"filterableAttributes": ["doggos.name"]}), + &json!({"facetName": "doggos.name", "facetQuery": "b"}), + |response, code| { + snapshot!(code, @"200 OK"); + snapshot!(response["facetHits"], @r###"[{"value":"bobby","count":1},{"value":"buddy","count":1}]"###); + }, + ) + .await; + + test_settings_documents_indexing_swapping_and_facet_search( + &NESTED_DOCUMENTS, + &json!({"filterableAttributes": [{"patterns": ["doggos.name"], "features": {"facetSearch": true, "filter": {"equality": false, "comparison": false}}}]}), + &json!({"facetName": "doggos.name", "facetQuery": "b"}), + |response, code| { + snapshot!(code, @"200 OK"); + snapshot!(response["facetHits"], @r###"[{"value":"bobby","count":1},{"value":"buddy","count":1}]"###); + }, + ).await; +} + +#[actix_rt::test] +async fn facet_search_with_filterable_attributes_rules_errors() { + test_settings_documents_indexing_swapping_and_facet_search( + &DOCUMENTS, + &json!({"filterableAttributes": ["genres"]}), + &json!({"facetName": "invalid", "facetQuery": "a"}), + |response, code| { + snapshot!(code, @"400 Bad Request"); + snapshot!(response["message"], @r###""Attribute `invalid` is not facet-searchable. Available facet-searchable attributes are: `genres`. To make it facet-searchable add it to the `filterableAttributes` index settings.""###); + }, + ) + .await; + + test_settings_documents_indexing_swapping_and_facet_search( + &DOCUMENTS, + &json!({"filterableAttributes": [{"patterns": ["genres"]}]}), + &json!({"facetName": "genres", "facetQuery": "a"}), + |response, code| { + snapshot!(code, @"400 Bad Request"); + snapshot!(response["message"], @r###""Attribute `genres` is not facet-searchable. This index does not have configured facet-searchable attributes. To make it facet-searchable add it to the `filterableAttributes` index settings.""###); + }, + ) + .await; + + test_settings_documents_indexing_swapping_and_facet_search( + &DOCUMENTS, + &json!({"filterableAttributes": [{"patterns": ["genres"], "features": {"facetSearch": false, "filter": {"equality": true, "comparison": true}}}]}), + &json!({"facetName": "genres", "facetQuery": "a"}), + |response, code| { + snapshot!(code, @"400 Bad Request"); + snapshot!(response["message"], @r###""Attribute `genres` is not facet-searchable. This index does not have configured facet-searchable attributes. To make it facet-searchable add it to the `filterableAttributes` index settings.""###); + }, + ).await; + + test_settings_documents_indexing_swapping_and_facet_search( + &DOCUMENTS, + &json!({"filterableAttributes": [{"patterns": ["genres"], "features": {"facetSearch": false, "filter": {"equality": false, "comparison": false}}}]}), + &json!({"facetName": "genres", "facetQuery": "a"}), + |response, code| { + snapshot!(code, @"400 Bad Request"); + snapshot!(response["message"], @r###""Attribute `genres` is not facet-searchable. This index does not have configured facet-searchable attributes. To make it facet-searchable add it to the `filterableAttributes` index settings.""###); + }, + ).await; + + test_settings_documents_indexing_swapping_and_facet_search( + &NESTED_DOCUMENTS, + &json!({"filterableAttributes": [{"patterns": ["doggos.name"]}]}), + &json!({"facetName": "invalid.name", "facetQuery": "b"}), + |response, code| { + snapshot!(code, @"400 Bad Request"); + snapshot!(response["message"], @r###""Attribute `invalid.name` is not facet-searchable. This index does not have configured facet-searchable attributes. To make it facet-searchable add it to the `filterableAttributes` index settings.""###); + }, + ) + .await; + + test_settings_documents_indexing_swapping_and_facet_search( + &NESTED_DOCUMENTS, + &json!({"filterableAttributes": [{"patterns": ["doggos.name"], "features": {"facetSearch": false, "filter": {"equality": true, "comparison": true}}}]}), + &json!({"facetName": "doggos.name", "facetQuery": "b"}), + |response, code| { + snapshot!(code, @"400 Bad Request"); + snapshot!(response["message"], @r###""Attribute `doggos.name` is not facet-searchable. This index does not have configured facet-searchable attributes. To make it facet-searchable add it to the `filterableAttributes` index settings.""###); + }, + ).await; + + test_settings_documents_indexing_swapping_and_facet_search( + &NESTED_DOCUMENTS, + &json!({"filterableAttributes": [{"patterns": ["doggos.name"], "features": {"facetSearch": false, "filter": {"equality": false, "comparison": false}}}]}), + &json!({"facetName": "doggos.name", "facetQuery": "b"}), + |response, code| { + snapshot!(code, @"400 Bad Request"); + snapshot!(response["message"], @r###""Attribute `doggos.name` is not facet-searchable. This index does not have configured facet-searchable attributes. To make it facet-searchable add it to the `filterableAttributes` index settings.""###); + }, + ).await; +} diff --git a/crates/meilisearch/tests/search/filters.rs b/crates/meilisearch/tests/search/filters.rs new file mode 100644 index 000000000..bb268ccf5 --- /dev/null +++ b/crates/meilisearch/tests/search/filters.rs @@ -0,0 +1,625 @@ +use meili_snap::{json_string, snapshot}; +use meilisearch::Opt; +use tempfile::TempDir; + +use super::test_settings_documents_indexing_swapping_and_search; +use crate::{ + common::{default_settings, shared_index_with_documents, Server, DOCUMENTS, NESTED_DOCUMENTS}, + json, +}; + +#[actix_rt::test] +async fn search_with_filter_string_notation() { + let server = Server::new().await; + let index = server.index("test"); + + let (_, code) = index.update_settings(json!({"filterableAttributes": ["title"]})).await; + meili_snap::snapshot!(code, @"202 Accepted"); + + let documents = DOCUMENTS.clone(); + let (task, code) = index.add_documents(documents, None).await; + meili_snap::snapshot!(code, @"202 Accepted"); + let res = index.wait_task(task.uid()).await; + meili_snap::snapshot!(res["status"], @r###""succeeded""###); + + index + .search( + json!({ + "filter": "title = Gläss" + }), + |response, code| { + assert_eq!(code, 200, "{}", response); + assert_eq!(response["hits"].as_array().unwrap().len(), 1); + }, + ) + .await; + + let index = server.index("nested"); + + let (_, code) = + index.update_settings(json!({"filterableAttributes": ["cattos", "doggos.age"]})).await; + meili_snap::snapshot!(code, @"202 Accepted"); + + let documents = NESTED_DOCUMENTS.clone(); + let (task, code) = index.add_documents(documents, None).await; + meili_snap::snapshot!(code, @"202 Accepted"); + let res = index.wait_task(task.uid()).await; + meili_snap::snapshot!(res["status"], @r###""succeeded""###); + + index + .search( + json!({ + "filter": "cattos = pésti" + }), + |response, code| { + assert_eq!(code, 200, "{}", response); + assert_eq!(response["hits"].as_array().unwrap().len(), 1); + assert_eq!(response["hits"][0]["id"], json!(852)); + }, + ) + .await; + + index + .search( + json!({ + "filter": "doggos.age > 5" + }), + |response, code| { + assert_eq!(code, 200, "{}", response); + assert_eq!(response["hits"].as_array().unwrap().len(), 2); + assert_eq!(response["hits"][0]["id"], json!(654)); + assert_eq!(response["hits"][1]["id"], json!(951)); + }, + ) + .await; +} + +#[actix_rt::test] +async fn search_with_filter_array_notation() { + let index = shared_index_with_documents().await; + let (response, code) = index + .search_post(json!({ + "filter": ["title = Gläss"] + })) + .await; + assert_eq!(code, 200, "{}", response); + assert_eq!(response["hits"].as_array().unwrap().len(), 1); + + let (response, code) = index + .search_post(json!({ + "filter": [["title = Gläss", "title = \"Shazam!\"", "title = \"Escape Room\""]] + })) + .await; + assert_eq!(code, 200, "{}", response); + assert_eq!(response["hits"].as_array().unwrap().len(), 3); +} + +#[actix_rt::test] +async fn search_with_contains_filter() { + let temp = TempDir::new().unwrap(); + let server = Server::new_with_options(Opt { + experimental_contains_filter: true, + ..default_settings(temp.path()) + }) + .await + .unwrap(); + let index = server.index("movies"); + + index.update_settings(json!({"filterableAttributes": ["title"]})).await; + + let documents = DOCUMENTS.clone(); + let (request, _code) = index.add_documents(documents, None).await; + index.wait_task(request.uid()).await.succeeded(); + + let (response, code) = index + .search_post(json!({ + "filter": "title CONTAINS cap" + })) + .await; + assert_eq!(code, 200, "{}", response); + assert_eq!(response["hits"].as_array().unwrap().len(), 2); +} + +#[actix_rt::test] +async fn search_with_pattern_filter_settings() { + // Check if the Equality filter works with patterns + test_settings_documents_indexing_swapping_and_search( + &NESTED_DOCUMENTS, + &json!({"filterableAttributes": [{"patterns": ["cattos","doggos.age"]}]}), + &json!({ + "filter": "cattos = pésti" + }), + |response, code| { + snapshot!(code, @"200 OK"); + snapshot!(json_string!(response["hits"]), @r###" + [ + { + "id": 852, + "father": "jean", + "mother": "michelle", + "doggos": [ + { + "name": "bobby", + "age": 2 + }, + { + "name": "buddy", + "age": 4 + } + ], + "cattos": "pésti" + } + ] + "###); + }, + ) + .await; + + test_settings_documents_indexing_swapping_and_search( + &NESTED_DOCUMENTS, + &json!({"filterableAttributes": [{ + "patterns": ["cattos","doggos.age"], + "features": { + "facetSearch": false, + "filter": {"equality": true, "comparison": false} + } + }]}), + &json!({ + "filter": "cattos = pésti" + }), + |response, code| { + snapshot!(code, @"200 OK"); + snapshot!(json_string!(response["hits"]), @r###" + [ + { + "id": 852, + "father": "jean", + "mother": "michelle", + "doggos": [ + { + "name": "bobby", + "age": 2 + }, + { + "name": "buddy", + "age": 4 + } + ], + "cattos": "pésti" + } + ] + "###); + }, + ) + .await; + + // Check if the Comparison filter works with patterns + test_settings_documents_indexing_swapping_and_search( + &NESTED_DOCUMENTS, + &json!({"filterableAttributes": [{ + "patterns": ["cattos","doggos.age"], + "features": { + "facetSearch": false, + "filter": {"equality": false, "comparison": true} + } + }]}), + &json!({ + "filter": "doggos.age > 2" + }), + |response, code| { + snapshot!(code, @"200 OK"); + snapshot!(json_string!(response["hits"]), @r###" + [ + { + "id": 852, + "father": "jean", + "mother": "michelle", + "doggos": [ + { + "name": "bobby", + "age": 2 + }, + { + "name": "buddy", + "age": 4 + } + ], + "cattos": "pésti" + }, + { + "id": 654, + "father": "pierre", + "mother": "sabine", + "doggos": [ + { + "name": "gros bill", + "age": 8 + } + ], + "cattos": [ + "simba", + "pestiféré" + ] + }, + { + "id": 951, + "father": "jean-baptiste", + "mother": "sophie", + "doggos": [ + { + "name": "turbo", + "age": 5 + }, + { + "name": "fast", + "age": 6 + } + ], + "cattos": [ + "moumoute", + "gomez" + ] + } + ] + "###); + }, + ) + .await; +} + +#[actix_rt::test] +async fn search_with_pattern_filter_settings_scenario_1() { + let temp = TempDir::new().unwrap(); + let server = Server::new_with_options(Opt { ..default_settings(temp.path()) }).await.unwrap(); + + eprintln!("Documents -> Settings -> test"); + let index = server.index("test"); + + let (task, code) = index.add_documents(NESTED_DOCUMENTS.clone(), None).await; + assert_eq!(code, 202, "{}", task); + let response = index.wait_task(task.uid()).await; + snapshot!(response["status"], @r###""succeeded""###); + + let (task, code) = index + .update_settings(json!({"filterableAttributes": [{ + "patterns": ["cattos","doggos.age"], + "features": { + "facetSearch": false, + "filter": {"equality": true, "comparison": false} + } + }]})) + .await; + assert_eq!(code, 202, "{}", task); + let response = index.wait_task(task.uid()).await; + snapshot!(response["status"], @r###""succeeded""###); + + // Check if the Equality filter works + index + .search( + json!({ + "filter": "cattos = pésti" + }), + |response, code| { + snapshot!(code, @"200 OK"); + snapshot!(json_string!(response["hits"]), @r###" + [ + { + "id": 852, + "father": "jean", + "mother": "michelle", + "doggos": [ + { + "name": "bobby", + "age": 2 + }, + { + "name": "buddy", + "age": 4 + } + ], + "cattos": "pésti" + } + ] + "###); + }, + ) + .await; + + // Check if the Comparison filter returns an error + index + .search( + json!({ + "filter": "doggos.age > 2" + }), + |response, code| { + snapshot!(code, @"400 Bad Request"); + snapshot!(json_string!(response), @r###" + { + "message": "Index `test`: Filter operator `>` is not allowed for the attribute `doggos.age`, allowed operators: OR, AND, NOT, =, !=, IN, IS EMPTY, IS NULL, EXISTS.", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + }, + ) + .await; + + // Update the settings activate comparison filter + let (task, code) = index + .update_settings(json!({"filterableAttributes": [{ + "patterns": ["cattos","doggos.age"], + "features": { + "facetSearch": false, + "filter": {"equality": true, "comparison": true} + } + }]})) + .await; + assert_eq!(code, 202, "{}", task); + let response = index.wait_task(task.uid()).await; + snapshot!(response["status"], @r###""succeeded""###); + + // Check if the Equality filter works + index + .search( + json!({ + "filter": "cattos = pésti" + }), + |response, code| { + snapshot!(code, @"200 OK"); + snapshot!(json_string!(response["hits"]), @r###" + [ + { + "id": 852, + "father": "jean", + "mother": "michelle", + "doggos": [ + { + "name": "bobby", + "age": 2 + }, + { + "name": "buddy", + "age": 4 + } + ], + "cattos": "pésti" + } + ] + "###); + }, + ) + .await; + + // Check if the Comparison filter works + index + .search( + json!({ + "filter": "doggos.age > 2" + }), + |response, code| { + snapshot!(code, @"200 OK"); + snapshot!(json_string!(response["hits"]), @r###" + [ + { + "id": 852, + "father": "jean", + "mother": "michelle", + "doggos": [ + { + "name": "bobby", + "age": 2 + }, + { + "name": "buddy", + "age": 4 + } + ], + "cattos": "pésti" + }, + { + "id": 654, + "father": "pierre", + "mother": "sabine", + "doggos": [ + { + "name": "gros bill", + "age": 8 + } + ], + "cattos": [ + "simba", + "pestiféré" + ] + }, + { + "id": 951, + "father": "jean-baptiste", + "mother": "sophie", + "doggos": [ + { + "name": "turbo", + "age": 5 + }, + { + "name": "fast", + "age": 6 + } + ], + "cattos": [ + "moumoute", + "gomez" + ] + } + ] + "###); + }, + ) + .await; + + // Update the settings deactivate equality filter + let (task, code) = index + .update_settings(json!({"filterableAttributes": [{ + "patterns": ["cattos","doggos.age"], + "features": { + "facetSearch": false, + "filter": {"equality": false, "comparison": true} + } + }]})) + .await; + assert_eq!(code, 202, "{}", task); + let response = index.wait_task(task.uid()).await; + snapshot!(response["status"], @r###""succeeded""###); + + // Check if the Equality filter returns an error + index + .search( + json!({ + "filter": "cattos = pésti" + }), + |response, code| { + snapshot!(code, @"400 Bad Request"); + snapshot!(json_string!(response), @r###" + { + "message": "Index `test`: Filter operator `=` is not allowed for the attribute `cattos`, allowed operators: OR, AND, NOT, <, >, <=, >=, TO, IS EMPTY, IS NULL, EXISTS.", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + }, + ) + .await; + + // Check if the Comparison filter works + index + .search( + json!({ + "filter": "doggos.age > 2" + }), + |response, code| { + snapshot!(code, @"200 OK"); + snapshot!(json_string!(response["hits"]), @r###" + [ + { + "id": 852, + "father": "jean", + "mother": "michelle", + "doggos": [ + { + "name": "bobby", + "age": 2 + }, + { + "name": "buddy", + "age": 4 + } + ], + "cattos": "pésti" + }, + { + "id": 654, + "father": "pierre", + "mother": "sabine", + "doggos": [ + { + "name": "gros bill", + "age": 8 + } + ], + "cattos": [ + "simba", + "pestiféré" + ] + }, + { + "id": 951, + "father": "jean-baptiste", + "mother": "sophie", + "doggos": [ + { + "name": "turbo", + "age": 5 + }, + { + "name": "fast", + "age": 6 + } + ], + "cattos": [ + "moumoute", + "gomez" + ] + } + ] + "###); + }, + ) + .await; + + // rollback the settings + let (task, code) = index + .update_settings(json!({"filterableAttributes": [{ + "patterns": ["cattos","doggos.age"], + "features": { + "facetSearch": false, + "filter": {"equality": true, "comparison": false} + } + }]})) + .await; + assert_eq!(code, 202, "{}", task); + let response = index.wait_task(task.uid()).await; + snapshot!(response["status"], @r###""succeeded""###); + + // Check if the Equality filter works + index + .search( + json!({ + "filter": "cattos = pésti" + }), + |response, code| { + snapshot!(code, @"200 OK"); + snapshot!(json_string!(response["hits"]), @r###" + [ + { + "id": 852, + "father": "jean", + "mother": "michelle", + "doggos": [ + { + "name": "bobby", + "age": 2 + }, + { + "name": "buddy", + "age": 4 + } + ], + "cattos": "pésti" + } + ] + "###); + }, + ) + .await; + + // Check if the Comparison filter returns an error + index + .search( + json!({ + "filter": "doggos.age > 2" + }), + |response, code| { + snapshot!(code, @"400 Bad Request"); + snapshot!(json_string!(response), @r###" + { + "message": "Index `test`: Filter operator `>` is not allowed for the attribute `doggos.age`, allowed operators: OR, AND, NOT, =, !=, IN, IS EMPTY, IS NULL, EXISTS.", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + }, + ) + .await; +} diff --git a/crates/meilisearch/tests/search/geo.rs b/crates/meilisearch/tests/search/geo.rs index b0cc8b6ca..a314ca241 100644 --- a/crates/meilisearch/tests/search/geo.rs +++ b/crates/meilisearch/tests/search/geo.rs @@ -1,9 +1,12 @@ use meili_snap::{json_string, snapshot}; +use meilisearch_types::milli::constants::RESERVED_GEO_FIELD_NAME; use once_cell::sync::Lazy; use crate::common::{Server, Value}; use crate::json; +use super::test_settings_documents_indexing_swapping_and_search; + static DOCUMENTS: Lazy = Lazy::new(|| { json!([ { @@ -184,3 +187,184 @@ async fn bug_4640() { ) .await; } + +#[actix_rt::test] +async fn geo_asc_with_words() { + let documents = json!([ + { "id": 0, "doggo": "jean", RESERVED_GEO_FIELD_NAME: { "lat": 0, "lng": 0 } }, + { "id": 1, "doggo": "intel", RESERVED_GEO_FIELD_NAME: { "lat": 88, "lng": 0 } }, + { "id": 2, "doggo": "jean bob", RESERVED_GEO_FIELD_NAME: { "lat": -89, "lng": 0 } }, + { "id": 3, "doggo": "jean michel", RESERVED_GEO_FIELD_NAME: { "lat": 0, "lng": 178 } }, + { "id": 4, "doggo": "bob marley", RESERVED_GEO_FIELD_NAME: { "lat": 0, "lng": -179 } }, + ]); + + test_settings_documents_indexing_swapping_and_search( + &documents, + &json!({"searchableAttributes": ["id", "doggo"], "rankingRules": ["words", "geo:asc"]}), + &json!({"q": "jean"}), + |response, code| { + assert_eq!(code, 200, "{}", response); + snapshot!(json_string!(response, { ".processingTimeMs" => "[time]" }), @r###" + { + "hits": [ + { + "id": 0, + "doggo": "jean", + "_geo": { + "lat": 0, + "lng": 0 + } + }, + { + "id": 2, + "doggo": "jean bob", + "_geo": { + "lat": -89, + "lng": 0 + } + }, + { + "id": 3, + "doggo": "jean michel", + "_geo": { + "lat": 0, + "lng": 178 + } + } + ], + "query": "jean", + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 3 + } + "###); + }, + ) + .await; + + test_settings_documents_indexing_swapping_and_search( + &documents, + &json!({"searchableAttributes": ["id", "doggo"], "rankingRules": ["words", "geo:asc"]}), + &json!({"q": "bob"}), + |response, code| { + assert_eq!(code, 200, "{}", response); + snapshot!(json_string!(response, { ".processingTimeMs" => "[time]" }), @r###" + { + "hits": [ + { + "id": 2, + "doggo": "jean bob", + "_geo": { + "lat": -89, + "lng": 0 + } + }, + { + "id": 4, + "doggo": "bob marley", + "_geo": { + "lat": 0, + "lng": -179 + } + } + ], + "query": "bob", + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 2 + } + "###); + }, + ) + .await; + + test_settings_documents_indexing_swapping_and_search( + &documents, + &json!({"searchableAttributes": ["id", "doggo"], "rankingRules": ["words", "geo:asc"]}), + &json!({"q": "intel"}), + |response, code| { + assert_eq!(code, 200, "{}", response); + snapshot!(json_string!(response, { ".processingTimeMs" => "[time]" }), @r###" + { + "hits": [ + { + "id": 1, + "doggo": "intel", + "_geo": { + "lat": 88, + "lng": 0 + } + } + ], + "query": "intel", + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 1 + } + "###); + }, + ) + .await; +} + +#[actix_rt::test] +async fn geo_sort_with_words() { + let documents = json!([ + { "id": 0, "doggo": "jean", RESERVED_GEO_FIELD_NAME: { "lat": 0, "lng": 0 } }, + { "id": 1, "doggo": "intel", RESERVED_GEO_FIELD_NAME: { "lat": 88, "lng": 0 } }, + { "id": 2, "doggo": "jean bob", RESERVED_GEO_FIELD_NAME: { "lat": -89, "lng": 0 } }, + { "id": 3, "doggo": "jean michel", RESERVED_GEO_FIELD_NAME: { "lat": 0, "lng": 178 } }, + { "id": 4, "doggo": "bob marley", RESERVED_GEO_FIELD_NAME: { "lat": 0, "lng": -179 } }, + ]); + + test_settings_documents_indexing_swapping_and_search( + &documents, + &json!({"searchableAttributes": ["id", "doggo"], "rankingRules": ["words", "sort"], "sortableAttributes": [RESERVED_GEO_FIELD_NAME]}), + &json!({"q": "jean", "sort": ["_geoPoint(0.0, 0.0):asc"]}), + |response, code| { + assert_eq!(code, 200, "{}", response); + snapshot!(json_string!(response, { ".processingTimeMs" => "[time]" }), @r###" + { + "hits": [ + { + "id": 0, + "doggo": "jean", + "_geo": { + "lat": 0, + "lng": 0 + }, + "_geoDistance": 0 + }, + { + "id": 2, + "doggo": "jean bob", + "_geo": { + "lat": -89, + "lng": 0 + }, + "_geoDistance": 9896348 + }, + { + "id": 3, + "doggo": "jean michel", + "_geo": { + "lat": 0, + "lng": 178 + }, + "_geoDistance": 19792697 + } + ], + "query": "jean", + "processingTimeMs": "[time]", + "limit": 20, + "offset": 0, + "estimatedTotalHits": 3 + } + "###); + }, + ) + .await; +} diff --git a/crates/meilisearch/tests/search/mod.rs b/crates/meilisearch/tests/search/mod.rs index a5fa94eea..2f3e60f34 100644 --- a/crates/meilisearch/tests/search/mod.rs +++ b/crates/meilisearch/tests/search/mod.rs @@ -4,6 +4,7 @@ mod distinct; mod errors; mod facet_search; +mod filters; mod formatted; mod geo; mod hybrid; @@ -21,10 +22,58 @@ use tempfile::TempDir; use crate::common::{ default_settings, shared_index_with_documents, shared_index_with_nested_documents, Server, - DOCUMENTS, FRUITS_DOCUMENTS, NESTED_DOCUMENTS, SCORE_DOCUMENTS, VECTOR_DOCUMENTS, + Value, DOCUMENTS, FRUITS_DOCUMENTS, NESTED_DOCUMENTS, SCORE_DOCUMENTS, VECTOR_DOCUMENTS, }; use crate::json; +async fn test_settings_documents_indexing_swapping_and_search( + documents: &Value, + settings: &Value, + query: &Value, + test: impl Fn(Value, actix_http::StatusCode) + std::panic::UnwindSafe + Clone, +) { + let temp = TempDir::new().unwrap(); + let server = Server::new_with_options(Opt { ..default_settings(temp.path()) }).await.unwrap(); + + eprintln!("Documents -> Settings -> test"); + let index = server.index("test"); + + let (task, code) = index.add_documents(documents.clone(), None).await; + assert_eq!(code, 202, "{}", task); + let response = index.wait_task(task.uid()).await; + assert!(response.is_success(), "{:?}", response); + + let (task, code) = index.update_settings(settings.clone()).await; + assert_eq!(code, 202, "{}", task); + let response = index.wait_task(task.uid()).await; + assert!(response.is_success(), "{:?}", response); + + index.search(query.clone(), test.clone()).await; + let (task, code) = server.delete_index("test").await; + assert_eq!(code, 202, "{}", task); + let response = server.wait_task(task.uid()).await; + assert!(response.is_success(), "{:?}", response); + + eprintln!("Settings -> Documents -> test"); + let index = server.index("test"); + + let (task, code) = index.update_settings(settings.clone()).await; + assert_eq!(code, 202, "{}", task); + let response = index.wait_task(task.uid()).await; + assert!(response.is_success(), "{:?}", response); + + let (task, code) = index.add_documents(documents.clone(), None).await; + assert_eq!(code, 202, "{}", task); + let response = index.wait_task(task.uid()).await; + assert!(response.is_success(), "{:?}", response); + + index.search(query.clone(), test.clone()).await; + let (task, code) = server.delete_index("test").await; + assert_eq!(code, 202, "{}", task); + let response = server.wait_task(task.uid()).await; + assert!(response.is_success(), "{:?}", response); +} + #[actix_rt::test] async fn simple_placeholder_search() { let index = shared_index_with_documents().await; @@ -355,118 +404,6 @@ async fn search_multiple_params() { .await; } -#[actix_rt::test] -async fn search_with_filter_string_notation() { - let server = Server::new().await; - let index = server.index("test"); - - let (_, code) = index.update_settings(json!({"filterableAttributes": ["title"]})).await; - meili_snap::snapshot!(code, @"202 Accepted"); - - let documents = DOCUMENTS.clone(); - let (task, code) = index.add_documents(documents, None).await; - meili_snap::snapshot!(code, @"202 Accepted"); - let res = index.wait_task(task.uid()).await; - meili_snap::snapshot!(res["status"], @r###""succeeded""###); - - index - .search( - json!({ - "filter": "title = Gläss" - }), - |response, code| { - assert_eq!(code, 200, "{}", response); - assert_eq!(response["hits"].as_array().unwrap().len(), 1); - }, - ) - .await; - - let index = server.index("nested"); - - let (_, code) = - index.update_settings(json!({"filterableAttributes": ["cattos", "doggos.age"]})).await; - meili_snap::snapshot!(code, @"202 Accepted"); - - let documents = NESTED_DOCUMENTS.clone(); - let (task, code) = index.add_documents(documents, None).await; - meili_snap::snapshot!(code, @"202 Accepted"); - let res = index.wait_task(task.uid()).await; - meili_snap::snapshot!(res["status"], @r###""succeeded""###); - - index - .search( - json!({ - "filter": "cattos = pésti" - }), - |response, code| { - assert_eq!(code, 200, "{}", response); - assert_eq!(response["hits"].as_array().unwrap().len(), 1); - assert_eq!(response["hits"][0]["id"], json!(852)); - }, - ) - .await; - - index - .search( - json!({ - "filter": "doggos.age > 5" - }), - |response, code| { - assert_eq!(code, 200, "{}", response); - assert_eq!(response["hits"].as_array().unwrap().len(), 2); - assert_eq!(response["hits"][0]["id"], json!(654)); - assert_eq!(response["hits"][1]["id"], json!(951)); - }, - ) - .await; -} - -#[actix_rt::test] -async fn search_with_filter_array_notation() { - let index = shared_index_with_documents().await; - let (response, code) = index - .search_post(json!({ - "filter": ["title = Gläss"] - })) - .await; - assert_eq!(code, 200, "{}", response); - assert_eq!(response["hits"].as_array().unwrap().len(), 1); - - let (response, code) = index - .search_post(json!({ - "filter": [["title = Gläss", "title = \"Shazam!\"", "title = \"Escape Room\""]] - })) - .await; - assert_eq!(code, 200, "{}", response); - assert_eq!(response["hits"].as_array().unwrap().len(), 3); -} - -#[actix_rt::test] -async fn search_with_contains_filter() { - let temp = TempDir::new().unwrap(); - let server = Server::new_with_options(Opt { - experimental_contains_filter: true, - ..default_settings(temp.path()) - }) - .await - .unwrap(); - let index = server.index("movies"); - - index.update_settings(json!({"filterableAttributes": ["title"]})).await; - - let documents = DOCUMENTS.clone(); - let (request, _code) = index.add_documents(documents, None).await; - index.wait_task(request.uid()).await.succeeded(); - - let (response, code) = index - .search_post(json!({ - "filter": "title CONTAINS cap" - })) - .await; - assert_eq!(code, 200, "{}", response); - assert_eq!(response["hits"].as_array().unwrap().len(), 2); -} - #[actix_rt::test] async fn search_with_sort_on_numbers() { let index = shared_index_with_documents().await; @@ -589,7 +526,7 @@ async fn search_facet_distribution() { |response, code| { assert_eq!(code, 200, "{}", response); let dist = response["facetDistribution"].as_object().unwrap(); - assert_eq!(dist.len(), 1); + assert_eq!(dist.len(), 1, "{:?}", dist); assert_eq!( dist["doggos.name"], json!({ "bobby": 1, "buddy": 1, "gros bill": 1, "turbo": 1, "fast": 1}) @@ -606,7 +543,7 @@ async fn search_facet_distribution() { |response, code| { assert_eq!(code, 200, "{}", response); let dist = response["facetDistribution"].as_object().unwrap(); - assert_eq!(dist.len(), 3); + assert_eq!(dist.len(), 3, "{:?}", dist); assert_eq!( dist["doggos.name"], json!({ "bobby": 1, "buddy": 1, "gros bill": 1, "turbo": 1, "fast": 1}) @@ -1559,6 +1496,293 @@ async fn change_attributes_settings() { .await; } +#[actix_rt::test] +async fn test_nested_fields() { + let documents = json!([ + { + "id": 0, + "title": "The zeroth document", + }, + { + "id": 1, + "title": "The first document", + "nested": { + "object": "field", + "machin": "bidule", + }, + }, + { + "id": 2, + "title": "The second document", + "nested": [ + "array", + { + "object": "field", + }, + { + "prout": "truc", + "machin": "lol", + }, + ], + }, + { + "id": 3, + "title": "The third document", + "nested": "I lied", + }, + ]); + + let settings = json!({ + "searchableAttributes": ["title", "nested.object", "nested.machin"], + "filterableAttributes": ["title", "nested.object", "nested.machin"] + }); + + // Test empty search returns all documents + test_settings_documents_indexing_swapping_and_search( + &documents, + &settings, + &json!({"q": "document"}), + |response, code| { + assert_eq!(code, 200, "{}", response); + snapshot!(json_string!(response["hits"]), @r###" + [ + { + "id": 0, + "title": "The zeroth document" + }, + { + "id": 1, + "title": "The first document", + "nested": { + "object": "field", + "machin": "bidule" + } + }, + { + "id": 2, + "title": "The second document", + "nested": [ + "array", + { + "object": "field" + }, + { + "prout": "truc", + "machin": "lol" + } + ] + }, + { + "id": 3, + "title": "The third document", + "nested": "I lied" + } + ] + "###); + }, + ) + .await; + + // Test searching specific documents + test_settings_documents_indexing_swapping_and_search( + &documents, + &settings, + &json!({"q": "zeroth"}), + |response, code| { + assert_eq!(code, 200, "{}", response); + snapshot!(json_string!(response["hits"]), @r###" + [ + { + "id": 0, + "title": "The zeroth document" + } + ] + "###); + }, + ) + .await; + + test_settings_documents_indexing_swapping_and_search( + &documents, + &settings, + &json!({"q": "first"}), + |response, code| { + assert_eq!(code, 200, "{}", response); + snapshot!(json_string!(response["hits"]), @r###" + [ + { + "id": 1, + "title": "The first document", + "nested": { + "object": "field", + "machin": "bidule" + } + } + ] + "###); + }, + ) + .await; + + // Test searching nested fields + test_settings_documents_indexing_swapping_and_search( + &documents, + &settings, + &json!({"q": "field"}), + |response, code| { + assert_eq!(code, 200, "{}", response); + snapshot!(json_string!(response["hits"]), @r###" + [ + { + "id": 1, + "title": "The first document", + "nested": { + "object": "field", + "machin": "bidule" + } + }, + { + "id": 2, + "title": "The second document", + "nested": [ + "array", + { + "object": "field" + }, + { + "prout": "truc", + "machin": "lol" + } + ] + } + ] + "###); + }, + ) + .await; + + test_settings_documents_indexing_swapping_and_search( + &documents, + &settings, + &json!({"q": "array"}), + |response, code| { + assert_eq!(code, 200, "{}", response); + // nested is not searchable + snapshot!(json_string!(response["hits"]), @"[]"); + }, + ) + .await; + + test_settings_documents_indexing_swapping_and_search( + &documents, + &settings, + &json!({"q": "lied"}), + |response, code| { + assert_eq!(code, 200, "{}", response); + // nested is not searchable + snapshot!(json_string!(response["hits"]), @"[]"); + }, + ) + .await; + + // Test filtering on nested fields + test_settings_documents_indexing_swapping_and_search( + &documents, + &settings, + &json!({"filter": "nested.object = field"}), + |response, code| { + assert_eq!(code, 200, "{}", response); + snapshot!(json_string!(response["hits"]), @r###" + [ + { + "id": 1, + "title": "The first document", + "nested": { + "object": "field", + "machin": "bidule" + } + }, + { + "id": 2, + "title": "The second document", + "nested": [ + "array", + { + "object": "field" + }, + { + "prout": "truc", + "machin": "lol" + } + ] + } + ] + "###); + }, + ) + .await; + + test_settings_documents_indexing_swapping_and_search( + &documents, + &settings, + &json!({"filter": "nested.machin = bidule"}), + |response, code| { + assert_eq!(code, 200, "{}", response); + snapshot!(json_string!(response["hits"]), @r###" + [ + { + "id": 1, + "title": "The first document", + "nested": { + "object": "field", + "machin": "bidule" + } + } + ] + "###); + }, + ) + .await; + + // Test filtering on non-filterable nested field fails + test_settings_documents_indexing_swapping_and_search( + &documents, + &settings, + &json!({"filter": "nested = array"}), + |response, code| { + assert_eq!(code, 400, "{}", response); + snapshot!(json_string!(response), @r###" + { + "message": "Index `test`: Attribute `nested` is not filterable. Available filterable attributes are: `nested.machin`, `nested.object`, `title`.\n1:7 nested = array", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + }, + ) + .await; + + // Test filtering on non-filterable nested field fails + test_settings_documents_indexing_swapping_and_search( + &documents, + &settings, + &json!({"filter": r#"nested = "I lied""#}), + |response, code| { + assert_eq!(code, 400, "{}", response); + snapshot!(json_string!(response), @r###" + { + "message": "Index `test`: Attribute `nested` is not filterable. Available filterable attributes are: `nested.machin`, `nested.object`, `title`.\n1:7 nested = \"I lied\"", + "code": "invalid_search_filter", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_filter" + } + "###); + }, + ) + .await; +} + /// Modifying facets with different casing should work correctly #[actix_rt::test] async fn change_facet_casing() {