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<Varchar> 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 <yiyang.li@zilliz.com>
This commit is contained in:
Li Yiyang
2026-07-10 17:48:34 +08:00
committed by GitHub
parent 694c2e6dba
commit f8130ad8d9
4 changed files with 540 additions and 2 deletions
+9
View File
@@ -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:
+2 -2
View File
@@ -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")
@@ -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"]
@@ -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<Varchar> 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