From f8130ad8d9a76c7209ce371eb1ee2114af8a828f Mon Sep 17 00:00:00 2001 From: Li Yiyang Date: Fri, 10 Jul 2026 17:48:34 +0800 Subject: [PATCH] test: add REST e2e coverage for regex and array updates (#51189) ## Summary - Add REST v2 e2e coverage for partial update `ARRAY_APPEND` and `ARRAY_REMOVE` field operations on Array fields. - Add REST v2 e2e coverage for regex filters across query, search, delete, JSON paths, Array elements, nullable VarChar fields, negation, boolean expressions, and invalid expressions. ## Test Plan - [x] `PYTHONPYCACHEPREFIX=/private/tmp/milvus_pycache /Users/yiyang.li/gitup/milvus/tests/python_client/.venv/bin/python -m py_compile tests/restful_client_v2/testcases/test_partial_update_array_op.py tests/restful_client_v2/testcases/test_regex_filter.py` - [x] `git diff --cached --check` --------- Signed-off-by: lyyyuna --- tests/restful_client_v2/base/testbase.py | 9 + tests/restful_client_v2/conftest.py | 4 +- .../testcases/test_partial_update_array_op.py | 181 +++++++++ .../testcases/test_regex_filter.py | 348 ++++++++++++++++++ 4 files changed, 540 insertions(+), 2 deletions(-) create mode 100644 tests/restful_client_v2/testcases/test_partial_update_array_op.py create mode 100644 tests/restful_client_v2/testcases/test_regex_filter.py diff --git a/tests/restful_client_v2/base/testbase.py b/tests/restful_client_v2/base/testbase.py index b2540a956e..cc0ce8699e 100644 --- a/tests/restful_client_v2/base/testbase.py +++ b/tests/restful_client_v2/base/testbase.py @@ -52,6 +52,15 @@ class Base: class TestBase(Base): req = None + @pytest.fixture(scope="class", autouse=True) + def init_class_config(self, endpoint, token): + self.endpoint = f"{endpoint}" + self.api_key = f"{token}" if token is not None else None + self.invalid_api_key = "invalid_token" + + def _class_scope_clients(self): + return CollectionClient(self.endpoint, self.api_key), VectorClient(self.endpoint, self.api_key) + def teardown_method(self): # Clean up collections if hasattr(self, "api_key") and self.api_key: diff --git a/tests/restful_client_v2/conftest.py b/tests/restful_client_v2/conftest.py index 8391e33fad..c661fcfef1 100644 --- a/tests/restful_client_v2/conftest.py +++ b/tests/restful_client_v2/conftest.py @@ -39,12 +39,12 @@ def pytest_addoption(parser): ) -@pytest.fixture +@pytest.fixture(scope="class") def endpoint(request): return request.config.getoption("--endpoint") -@pytest.fixture +@pytest.fixture(scope="class") def token(request): return request.config.getoption("--token") diff --git a/tests/restful_client_v2/testcases/test_partial_update_array_op.py b/tests/restful_client_v2/testcases/test_partial_update_array_op.py new file mode 100644 index 0000000000..d2effff756 --- /dev/null +++ b/tests/restful_client_v2/testcases/test_partial_update_array_op.py @@ -0,0 +1,181 @@ +import pytest +from base.testbase import TestBase +from utils.constant import CaseLabel +from utils.utils import gen_collection_name + + +class TestPartialUpdateArrayOp(TestBase): + def _create_array_collection(self): + name = gen_collection_name() + self.name = name + payload = { + "collectionName": name, + "schema": { + "fields": [ + {"fieldName": "id", "dataType": "Int64", "isPrimary": True, "elementTypeParams": {}}, + {"fieldName": "name", "dataType": "VarChar", "elementTypeParams": {"max_length": "64"}}, + { + "fieldName": "tags", + "dataType": "Array", + "elementDataType": "Int64", + "elementTypeParams": {"max_capacity": "16"}, + }, + { + "fieldName": "labels", + "dataType": "Array", + "elementDataType": "VarChar", + "elementTypeParams": {"max_capacity": "16", "max_length": "64"}, + }, + {"fieldName": "vector", "dataType": "FloatVector", "elementTypeParams": {"dim": "4"}}, + ] + }, + "indexParams": [{"fieldName": "vector", "indexName": "vector_index", "metricType": "L2"}], + } + rsp = self.collection_client.collection_create(payload) + assert rsp["code"] == 0, rsp + return name + + def _insert_base_rows(self, name): + rows = [ + { + "id": 0, + "name": "row_0", + "tags": [1, 2], + "labels": ["a", "b", "a"], + "vector": [0.1, 0.2, 0.3, 0.4], + }, + { + "id": 1, + "name": "row_1", + "tags": [10, 20, 10, 30], + "labels": ["x", "y"], + "vector": [0.2, 0.3, 0.4, 0.5], + }, + ] + rsp = self.vector_client.vector_insert({"collectionName": name, "data": rows}) + assert rsp["code"] == 0, rsp + self.collection_client.flush(name) + + def _query_rows(self, name): + rsp = self.vector_client.vector_query( + { + "collectionName": name, + "filter": "id >= 0", + "outputFields": ["id", "name", "tags", "labels"], + } + ) + assert rsp["code"] == 0, rsp + rows = {row["id"]: row for row in rsp["data"]} + for row in rows.values(): + row["tags"] = self._array_data(row["tags"]) + row["labels"] = self._array_data(row["labels"]) + return rows + + @staticmethod + def _array_data(value): + if not isinstance(value, dict): + return value + + data = value.get("Data", value.get("data")) + if not isinstance(data, dict): + return value + + for field_name in ("LongData", "StringData", "BoolData", "FloatData", "DoubleData", "IntData"): + typed_data = data.get(field_name) + if isinstance(typed_data, dict) and "data" in typed_data: + return typed_data["data"] + return value + + @pytest.mark.tags(CaseLabel.L0) + def test_partial_update_array_append(self): + """ + target: verify REST upsert fieldOps ARRAY_APPEND appends array payloads + method: insert rows, upsert only tags with ARRAY_APPEND, then query rows + expected: tags are appended and non-updated fields are preserved + """ + name = self._create_array_collection() + self._insert_base_rows(name) + + payload = { + "collectionName": name, + "data": [{"id": 0, "tags": [3, 4]}, {"id": 1, "tags": [40]}], + "partialUpdate": True, + "fieldOps": [{"fieldName": "tags", "op": "ARRAY_APPEND"}], + } + rsp = self.vector_client.vector_upsert(payload) + assert rsp["code"] == 0, rsp + + rows = self._query_rows(name) + assert rows[0]["tags"] == [1, 2, 3, 4] + assert rows[1]["tags"] == [10, 20, 10, 30, 40] + assert rows[0]["name"] == "row_0" + + @pytest.mark.tags(CaseLabel.L0) + def test_partial_update_array_remove(self): + """ + target: verify REST upsert fieldOps ARRAY_REMOVE removes matching array elements + method: insert repeated array values, remove one value through fieldOps + expected: all matching values are removed from the target array field + """ + name = self._create_array_collection() + self._insert_base_rows(name) + + payload = { + "collectionName": name, + "data": [{"id": 1, "tags": [10]}], + "partialUpdate": True, + "fieldOps": [{"fieldName": "tags", "op": "ARRAY_REMOVE"}], + } + rsp = self.vector_client.vector_upsert(payload) + assert rsp["code"] == 0, rsp + + rows = self._query_rows(name) + assert rows[1]["tags"] == [20, 30] + assert rows[1]["labels"] == ["x", "y"] + + @pytest.mark.tags(CaseLabel.L1) + def test_partial_update_array_multiple_field_ops(self): + """ + target: verify REST upsert accepts multiple array fieldOps in one request + method: append Int64 array field and remove VarChar array field together + expected: each field uses its own op and unchanged fields are preserved + """ + name = self._create_array_collection() + self._insert_base_rows(name) + + payload = { + "collectionName": name, + "data": [{"id": 0, "tags": [3], "labels": ["a"]}], + "partialUpdate": True, + "fieldOps": [ + {"fieldName": "tags", "op": "ARRAY_APPEND"}, + {"fieldName": "labels", "op": "ARRAY_REMOVE"}, + ], + } + rsp = self.vector_client.vector_upsert(payload) + assert rsp["code"] == 0, rsp + + rows = self._query_rows(name) + assert rows[0]["tags"] == [1, 2, 3] + assert rows[0]["labels"] == ["b"] + assert rows[0]["name"] == "row_0" + + @pytest.mark.tags(CaseLabel.L1) + def test_partial_update_array_op_rejects_non_array_field(self): + """ + target: verify REST rejects ARRAY_APPEND on a non-Array field + method: send fieldOps ARRAY_APPEND for VarChar field name + expected: request fails with a clear non-Array field validation error + """ + name = self._create_array_collection() + self._insert_base_rows(name) + + payload = { + "collectionName": name, + "data": [{"id": 0, "name": "bad_append"}], + "partialUpdate": True, + "fieldOps": [{"fieldName": "name", "op": "ARRAY_APPEND"}], + } + rsp = self.vector_client.vector_upsert(payload) + assert rsp["code"] != 0, rsp + assert 'op ARRAY_APPEND requires Array field, but field "name" is VarChar' in rsp["message"] diff --git a/tests/restful_client_v2/testcases/test_regex_filter.py b/tests/restful_client_v2/testcases/test_regex_filter.py new file mode 100644 index 0000000000..62843146bc --- /dev/null +++ b/tests/restful_client_v2/testcases/test_regex_filter.py @@ -0,0 +1,348 @@ +import pytest +from base.testbase import TestBase +from utils.constant import CaseLabel +from utils.utils import gen_collection_name + +DIM = 4 + + +def _vec(seed): + return [float(seed), 0.0, 0.0, 0.0] + + +def _regex_rows(): + return [ + { + "id": 1, + "vec": _vec(1), + "text": "ERROR E1001: connection timeout", + "email": "alice@gmail.com", + "url": "/api/v1/users/123", + "level": "ERROR", + "metadata": { + "level": "ERROR", + "version": "v1.2", + "trace": "abc-123", + "version_num": 12, + "enabled": True, + "nested": {"x": "abc"}, + "arr": ["abc"], + }, + "tags": ["release-v1", "prod"], + }, + { + "id": 2, + "vec": _vec(2), + "text": "WARN W2002: retry later", + "email": "bob@example.com", + "url": "/api/v2/orders/456", + "level": "WARN", + "metadata": { + "level": "WARN", + "version": "v2.0", + "trace": "def-456", + "version_num": 20, + "enabled": False, + "nested": {"x": "def"}, + "arr": ["def"], + }, + "tags": ["release-v2", "staging"], + }, + { + "id": 3, + "vec": _vec(3), + "text": "DEBUG cache hit", + "email": "carol@GMAIL.com", + "url": "/api/v10/users/789", + "level": "DEBUG", + "metadata": { + "level": "DEBUG", + "version": "v10.1", + "trace": "ghi-789", + "version_num": 101, + "enabled": True, + "nested": {"x": "ghi"}, + "arr": ["ghi"], + }, + "tags": ["debug", "dev"], + }, + { + "id": 4, + "vec": _vec(4), + "text": "cn log error code 555-1234", + "email": None, + "url": "/static/index.html", + "level": "INFO", + "metadata": { + "level": "INFO", + "version": "alpha", + "trace": None, + "version_num": 0, + "enabled": False, + "nested": {"x": "cn"}, + "arr": ["cn"], + }, + "tags": ["cn", "release-alpha"], + }, + { + "id": 5, + "vec": _vec(5), + "text": "multi\nline c\nd pattern", + "email": "dave@gmail.com", + "url": "/api/v1/users/search", + "level": "ERROR", + "metadata": { + "level": "ERROR", + "version": "v1.3", + "trace": "jkl-000", + "version_num": 13, + "enabled": True, + "nested": {"x": "jkl"}, + "arr": ["jkl"], + }, + "tags": ["release-v1-hotfix", "prod"], + }, + { + "id": 6, + "vec": _vec(6), + "text": "", + "email": "empty@gmail.com", + "url": "", + "level": "", + "metadata": { + "level": "", + "version": "", + "trace": "", + "version_num": None, + "enabled": False, + "nested": {}, + "arr": [], + }, + "tags": ["", "empty"], + }, + { + "id": 7, + "vec": _vec(7), + "text": "status OK deploy success", + "email": "emo@dev.io", + "url": "/api/health", + "level": "INFO", + "metadata": { + "level": "INFO", + "version": "v1.0", + "trace": "emoji-001", + "version_num": 10, + "enabled": True, + "nested": {"x": "ok"}, + "arr": ["ok"], + }, + "tags": ["emoji", "dev"], + }, + ] + + +class TestRegexFilter(TestBase): + def setup_class(self): + self.collection_name = self.__class__.__name__ + gen_collection_name() + + @pytest.fixture(scope="class", autouse=True) + def prepare_shared_regex_collection(self, request, init_class_config): + collection_client, vector_client = self._class_scope_clients() + + def teardown(): + collection_client.collection_drop({"collectionName": self.collection_name}) + + request.addfinalizer(teardown) + self._create_regex_collection(self.collection_name, collection_client, vector_client) + + def _create_regex_collection(self, name, collection_client, vector_client): + payload = { + "collectionName": name, + "schema": { + "autoId": False, + "enableDynamicField": False, + "fields": [ + {"fieldName": "id", "dataType": "Int64", "isPrimary": True}, + {"fieldName": "vec", "dataType": "FloatVector", "elementTypeParams": {"dim": str(DIM)}}, + {"fieldName": "text", "dataType": "VarChar", "elementTypeParams": {"max_length": "512"}}, + { + "fieldName": "email", + "dataType": "VarChar", + "nullable": True, + "elementTypeParams": {"max_length": "256"}, + }, + {"fieldName": "url", "dataType": "VarChar", "elementTypeParams": {"max_length": "512"}}, + {"fieldName": "level", "dataType": "VarChar", "elementTypeParams": {"max_length": "32"}}, + {"fieldName": "metadata", "dataType": "JSON"}, + { + "fieldName": "tags", + "dataType": "Array", + "elementDataType": "VarChar", + "elementTypeParams": {"max_capacity": "8", "max_length": "128"}, + }, + ], + }, + "indexParams": [{"fieldName": "vec", "indexName": "vec_index", "metricType": "L2"}], + } + rsp = collection_client.collection_create(payload) + assert rsp["code"] == 0, rsp + collection_client.wait_load_completed(name, timeout=60) + + rows = _regex_rows() + rsp = vector_client.vector_insert({"collectionName": name, "data": rows}) + assert rsp["code"] == 0, rsp + assert rsp["data"]["insertCount"] == len(rows) + collection_client.flush(name) + + def _shared_collection(self): + return self.collection_name + + def _query_ids(self, name, filter_expr, timeout=1): + rsp = self.vector_client.vector_query( + {"collectionName": name, "filter": filter_expr, "outputFields": ["id"], "limit": 100}, + timeout=timeout, + ) + assert rsp["code"] == 0, rsp + return sorted(row["id"] for row in rsp.get("data", [])) + + @pytest.mark.tags(CaseLabel.L0) + def test_regex_query_basic_semantics(self): + """ + target: verify REST query supports core regex =~ semantics + method: query VarChar field with substring, anchors, classes, dot-newline, and empty pattern + expected: returned ids match PyMilvus regex filter semantics + """ + name = self._shared_collection() + + cases = [ + ('text =~ "timeout"', [1]), + ('text =~ "ERROR"', [1]), + ('text =~ "^ERROR"', [1]), + ('text =~ "hit$"', [3]), + ('text =~ "^DEBUG cache hit$"', [3]), + (r'text =~ "E[0-9]{4}:"', [1]), + (r'text =~ "[0-9]{3}-[0-9]{4}"', [4]), + ('text =~ "c.d"', [4, 5]), + ('text =~ "(?-s)c.d"', [4]), + ('text =~ ""', [1, 2, 3, 4, 5, 6, 7]), + ('text =~ "^$"', [6]), + ] + for filter_expr, expected in cases: + assert self._query_ids(name, filter_expr) == expected + + @pytest.mark.tags(CaseLabel.L1) + def test_regex_query_negation_and_boolean_expression(self): + """ + target: verify REST query supports !~, not(=~), and boolean combinations + method: compare negated regex and combined scalar predicates + expected: !~ is equivalent to not(=~) and boolean precedence is respected + """ + name = self._shared_collection() + + assert self._query_ids(name, 'text !~ "^DEBUG"') == [1, 2, 4, 5, 6, 7] + assert self._query_ids(name, 'not (text =~ "^DEBUG")') == [1, 2, 4, 5, 6, 7] + assert self._query_ids(name, 'text =~ "ERROR" and id > 1') == [] + assert self._query_ids(name, 'text =~ "(?i)error" and id > 1') == [4] + assert self._query_ids(name, 'text =~ "^WARN" or text =~ "^DEBUG"') == [2, 3] + + @pytest.mark.tags(CaseLabel.L1) + def test_regex_query_json_array_and_nullable_paths(self): + """ + target: verify REST query supports regex on JSON paths, Array elements, and nullable fields + method: query JSON string paths, array element paths, and nullable VarChar with =~ and !~ + expected: string paths match, non-string/missing paths return empty, null values are excluded unless explicit + """ + name = self._shared_collection() + + assert self._query_ids(name, r'metadata["version"] =~ "^v[0-9]+\.[0-9]+$"') == [1, 2, 3, 5, 7] + assert self._query_ids(name, 'metadata["nested"]["x"] =~ "^abc$"') == [1] + assert self._query_ids(name, 'metadata["nested"]["x"] !~ "^abc$"') == [2, 3, 4, 5, 7] + assert self._query_ids(name, r'metadata["trace"] =~ "[a-z]+-[0-9]+"') == [1, 2, 3, 5, 7] + assert self._query_ids(name, 'metadata["version_num"] =~ "1"', timeout=0) == [] + assert self._query_ids(name, 'metadata["nested"]["missing"] =~ ".*"', timeout=0) == [] + + assert self._query_ids(name, 'tags[0] =~ "^release-v[0-9]+"') == [1, 2, 5] + assert self._query_ids(name, 'tags[0] !~ "^release"') == [3, 4, 6, 7] + assert self._query_ids(name, 'tags[10] =~ ".*"', timeout=0) == [] + assert self._query_ids(name, 'tags[0] =~ "^$"') == [6] + + assert self._query_ids(name, 'email =~ "gmail"') == [1, 5, 6] + assert self._query_ids(name, 'email !~ "gmail"') == [2, 3, 7] + assert self._query_ids(name, 'email !~ "gmail" or email is null') == [2, 3, 4, 7] + + @pytest.mark.tags(CaseLabel.L0) + def test_regex_search_filter(self): + """ + target: verify REST vector search supports regex filters + method: run unfiltered search then filtered search with regex on url field + expected: filtered search only returns ids with /api/v[0-9]+/users urls + """ + name = self._shared_collection() + + unfiltered = self.vector_client.vector_search( + { + "collectionName": name, + "data": [_vec(1)], + "annsField": "vec", + "limit": 7, + "outputFields": ["id"], + "searchParams": {"metricType": "L2", "params": {}}, + } + ) + assert unfiltered["code"] == 0, unfiltered + assert {row["id"] for row in unfiltered["data"]} == {1, 2, 3, 4, 5, 6, 7} + + filtered = self.vector_client.vector_search( + { + "collectionName": name, + "data": [_vec(1)], + "annsField": "vec", + "filter": 'url =~ "^/api/v[0-9]+/users"', + "limit": 3, + "outputFields": ["id", "url"], + "searchParams": {"metricType": "L2", "params": {}}, + } + ) + assert filtered["code"] == 0, filtered + assert {row["id"] for row in filtered["data"]} == {1, 3, 5} + assert all(row["url"].startswith("/api/v") for row in filtered["data"]) + + @pytest.mark.tags(CaseLabel.L1) + def test_regex_delete_filter(self): + """ + target: verify REST delete supports regex filters + method: delete rows where text starts with DEBUG, then query remaining ids + expected: id 3 is removed and other rows remain visible + """ + name = gen_collection_name() + self.name = name + self._create_regex_collection(name, self.collection_client, self.vector_client) + + rsp = self.vector_client.vector_delete({"collectionName": name, "filter": 'text =~ "^DEBUG"'}) + assert rsp["code"] == 0, rsp + + assert self._query_ids(name, "id > 0") == [1, 2, 4, 5, 6, 7] + + @pytest.mark.tags(CaseLabel.L1) + def test_regex_invalid_expressions(self): + """ + target: verify REST rejects invalid regex expressions + method: send invalid pattern, unsupported field type, direct array field, and non-string RHS + expected: each request fails with a clear validation error + """ + name = self._shared_collection() + + cases = [ + ('text =~ "(unclosed"', "missing closing"), + ('text =~ "(a)\\\\1"', "invalid regex pattern"), + ('id =~ "1"', "regex match on non-string or non-json field"), + ('tags =~ "prod"', "can not comparisons array fields directly"), + ("text =~ 123", "string literal or template variable"), + ('unknown_field =~ "test"', "field unknown_field not exist"), + ] + for filter_expr, message in cases: + rsp = self.vector_client.vector_query( + {"collectionName": name, "filter": filter_expr, "outputFields": ["id"], "limit": 10} + ) + assert rsp["code"] != 0, rsp + assert message in rsp["message"], rsp