diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml index 724506f9..8557b0f2 100644 --- a/.code-samples.meilisearch.yaml +++ b/.code-samples.meilisearch.yaml @@ -622,3 +622,29 @@ update_embedders_1: |- }) reset_embedders_1: |- client.index('INDEX_NAME').reset_embedders() + +list_dynamic_search_rules_1: |- + client.index('movies').list_dynamic_search_rules() + +get_dynamic_search_rule_1: |- + client.index('movies').get_dynamic_search_rule('promote-new') + +patch_dynamic_search_rule_1: |- + client.index('movies').upsert_dynamic_search_rule('promote-new', { + 'conditions': [ + { + 'scope': ['title'], + 'operator': 'contains', + 'value': 'new' + } + ], + 'actions': [ + { + 'action': 'promote', + 'documentIds': ['1', '2'], + 'matchCondition': 'all' + } + ] + }) +delete_dynamic_search_rule_1: |- + client.index('movies').delete_dynamic_search_rule('promote-new') diff --git a/meilisearch/config.py b/meilisearch/config.py index 3b9fa457..76ef3709 100644 --- a/meilisearch/config.py +++ b/meilisearch/config.py @@ -51,6 +51,7 @@ class Paths: experimental_features = "experimental-features" webhooks = "webhooks" export = "export" + dynamic_search_rules = "dynamic-search-rules" def __init__( self, diff --git a/meilisearch/index.py b/meilisearch/index.py index 6c65874a..36e8c75d 100644 --- a/meilisearch/index.py +++ b/meilisearch/index.py @@ -1309,6 +1309,112 @@ def reset_settings(self, *, metadata: Optional[str] = None) -> TaskInfo: return TaskInfo(**task) + + # DYNAMIC SEARCH RULES SUB-ROUTES + + def list_dynamic_search_rules( + self, parameters: Optional[MutableMapping[str, Any]] = None + ) -> Dict[str, Any]: + """List all dynamic search rules of the index. + + Parameters + ---------- + parameters (optional): + parameters accepted by the list dynamic search rules route, including pagination and filtering options. + + Returns + ------- + rules: dict + Dictionary containing the list of dynamic search rules and pagination info. + + Raises + ------ + MeilisearchApiError + An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors + """ + if parameters is None: + parameters = {} + + return self.http.post( + f"{self.config.paths.index}/{self.uid}/{self.config.paths.dynamic_search_rules}/fetch", + body=parameters, + ) + + def get_dynamic_search_rule(self, uid: str) -> Dict[str, Any]: + """Get a single dynamic search rule by uid. + + Parameters + ---------- + uid: str + Unique identifier of the dynamic search rule. + + Returns + ------- + rule: dict + Dictionary containing the dynamic search rule data. + + Raises + ------ + MeilisearchApiError + An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors + """ + return self.http.get( + f"{self.config.paths.index}/{self.uid}/{self.config.paths.dynamic_search_rules}/{uid}" + ) + + def upsert_dynamic_search_rule(self, uid: str, body: Dict[str, Any]) -> TaskInfo: + """Create or update a dynamic search rule. + + Parameters + ---------- + uid: str + Unique identifier of the dynamic search rule. + body: dict + Dictionary containing the dynamic search rule configuration. + + Returns + ------- + task_info: + TaskInfo instance containing information about a task to track the progress of an asynchronous process. + https://www.meilisearch.com/docs/reference/api/tasks#get-one-task + + Raises + ------ + MeilisearchApiError + An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors + """ + task = self.http.patch( + f"{self.config.paths.index}/{self.uid}/{self.config.paths.dynamic_search_rules}/{uid}", + body, + ) + + return TaskInfo(**task) + + def delete_dynamic_search_rule(self, uid: str) -> TaskInfo: + """Delete a dynamic search rule by uid. + + Parameters + ---------- + uid: str + Unique identifier of the dynamic search rule to delete. + + Returns + ------- + task_info: + TaskInfo instance containing information about a task to track the progress of an asynchronous process. + https://www.meilisearch.com/docs/reference/api/tasks#get-one-task + + Raises + ------ + MeilisearchApiError + An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors + """ + task = self.http.delete( + f"{self.config.paths.index}/{self.uid}/{self.config.paths.dynamic_search_rules}/{uid}" + ) + + return TaskInfo(**task) + # RANKING RULES SUB-ROUTES def get_ranking_rules(self) -> List[str]: diff --git a/tests/index/test_index_dynamic_search_rules_meilisearch.py b/tests/index/test_index_dynamic_search_rules_meilisearch.py new file mode 100644 index 00000000..46fbc969 --- /dev/null +++ b/tests/index/test_index_dynamic_search_rules_meilisearch.py @@ -0,0 +1,152 @@ +import pytest +from meilisearch.errors import MeilisearchApiError + + +@pytest.fixture +def test_index(client_with_index): + index = client_with_index() + yield index + index.delete() + + +def test_list_dynamic_search_rules(test_index): + """Test listing dynamic search rules""" + response = test_index.list_dynamic_search_rules() + assert isinstance(response, dict) + assert "results" in response or "meta" in response + if "results" in response: + assert isinstance(response["results"], list) + + +def test_get_dynamic_search_rule(test_index): + """Test getting a single dynamic search rule""" + rule_uid = "test-rule-1" + rule_body = { + "conditions": [ + { + "scope": ["title"], + "operator": "contains", + "value": "new" + } + ], + "actions": [ + { + "action": "promote", + "documentIds": ["1"], + "matchCondition": "all", + "position": 1 + } + ] + } + + create_response = test_index.upsert_dynamic_search_rule(rule_uid, rule_body) + test_index.wait_for_task(create_response.task_uid) + + response = test_index.get_dynamic_search_rule(rule_uid) + assert isinstance(response, dict) + assert response.get("uid") == rule_uid + + +def test_upsert_dynamic_search_rule(test_index): + """Test creating or updating a dynamic search rule""" + rule_uid = "test-rule-2" + rule_body = { + "conditions": [ + { + "scope": ["title"], + "operator": "contains", + "value": "hello" + } + ], + "actions": [ + { + "action": "promote", + "documentIds": ["2"], + "matchCondition": "all", + "position": 1 + } + ] + } + + response = test_index.upsert_dynamic_search_rule(rule_uid, rule_body) + assert response.task_uid is not None + + test_index.wait_for_task(response.task_uid) + + retrieved = test_index.get_dynamic_search_rule(rule_uid) + assert retrieved.get("uid") == rule_uid + + +def test_delete_dynamic_search_rule(test_index): + """Test deleting a dynamic search rule""" + rule_uid = "test-rule-3" + rule_body = { + "conditions": [ + { + "scope": ["title"], + "operator": "contains", + "value": "delete-me" + } + ], + "actions": [ + { + "action": "promote", + "documentIds": ["3"], + "matchCondition": "all", + "position": 1 + } + ] + } + + create_response = test_index.upsert_dynamic_search_rule(rule_uid, rule_body) + test_index.wait_for_task(create_response.task_uid) + + delete_response = test_index.delete_dynamic_search_rule(rule_uid) + assert delete_response.task_uid is not None + + test_index.wait_for_task(delete_response.task_uid) + + with pytest.raises(MeilisearchApiError): + test_index.get_dynamic_search_rule(rule_uid) + + +def test_upsert_dynamic_search_rule_index_isolation(client_with_index): + """Test that upsert on one index does not affect another index""" + index_a = client_with_index() + index_b = client_with_index() + + rule_uid = "isolation-rule" + body_a = { + "conditions": [ + {"scope": ["title"], "operator": "contains", "value": "foo"} + ], + "actions": [ + {"action": "promote", "documentIds": ["1"], "matchCondition": "all", "position": 1} + ], + } + body_b = { + "conditions": [ + {"scope": ["title"], "operator": "contains", "value": "bar"} + ], + "actions": [ + {"action": "promote", "documentIds": ["2"], "matchCondition": "all", "position": 2} + ], + } + + try: + task_a = index_a.upsert_dynamic_search_rule(rule_uid, body_a) + index_a.wait_for_task(task_a.task_uid) + + task_b = index_b.upsert_dynamic_search_rule(rule_uid, body_b) + index_b.wait_for_task(task_b.task_uid) + + rule_a = index_a.get_dynamic_search_rule(rule_uid) + rule_b = index_b.get_dynamic_search_rule(rule_uid) + + assert rule_a.get("uid") == rule_uid + assert rule_b.get("uid") == rule_uid + # Rules should be independent per index + assert rule_a != rule_b + finally: + index_a.delete() + index_b.delete()