Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .code-samples.meilisearch.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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')
1 change: 1 addition & 0 deletions meilisearch/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class Paths:
experimental_features = "experimental-features"
webhooks = "webhooks"
export = "export"
dynamic_search_rules = "dynamic-search-rules"

def __init__(
self,
Expand Down
106 changes: 106 additions & 0 deletions meilisearch/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
152 changes: 152 additions & 0 deletions tests/index/test_index_dynamic_search_rules_meilisearch.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +50 to +77
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify path construction consistency across dynamic search rule methods.
rg -n -C3 'def (upsert_dynamic_search_rule|get_dynamic_search_rule|delete_dynamic_search_rule)\(' meilisearch/index.py
rg -n -C2 'dynamic_search_rules.*uid' meilisearch/index.py

Repository: meilisearch/meilisearch-python

Length of output: 1319


Add a multi-index isolation test to catch endpoint scoping regressions in upsert_dynamic_search_rule.

The current tests use only a single index, which masks a critical bug: upsert_dynamic_search_rule builds a non-index-scoped path (meilisearch/index.py:1387) while get_dynamic_search_rule (line 1362) and delete_dynamic_search_rule (line 1413) both include the index UID. This path inconsistency means upsert operations affect all indexes instead of being isolated per-index. Add a test that upserts the same rule_uid in two separate indexes and verifies that retrieval returns the correct rule per index.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/index/test_index_dynamic_search_rules_meilisearch.py` around lines 50 -
77, Add a test that verifies multi-index isolation for dynamic search rules by
creating two separate TestIndex instances and upserting the same rule_uid into
each using upsert_dynamic_search_rule, then waiting for tasks and calling
get_dynamic_search_rule on each index to assert each index returns its own rule
(uid and body) and that they do not overwrite each other; reference
upsert_dynamic_search_rule, get_dynamic_search_rule, and
delete_dynamic_search_rule to locate the related code paths (the bug is that
upsert builds a non-index-scoped path), so ensure the test reproduces the
endpoint-scoping mismatch by asserting isolation and failing if upsert affects
both indexes.



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()