chore: import upstream snapshot with attribution
Build and test / Build and test AMD64 Ubuntu 22.04 (push) Failing after 0s
Publish Builder / amazonlinux2023 (push) Failing after 1s
Build and test / UT for Go (push) Has been skipped
Publish KRTE Images / KRTE (push) Failing after 1s
Build and test / Integration Test (push) Has been skipped
Build and test / Upload Code Coverage (push) Has been skipped
Publish Builder / rockylinux9 (push) Failing after 1s
Publish Builder / ubuntu22.04 (push) Failing after 0s
Publish Builder / ubuntu24.04 (push) Failing after 0s
Publish Gpu Builder / publish-gpu-builder (push) Failing after 1s
Publish Test Images / PyTest (push) Failing after 0s
Build and test / UT for Cpp (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:17 +08:00
commit 498b235461
5446 changed files with 2748612 additions and 0 deletions
@@ -0,0 +1,209 @@
# Expression Filtering Tests
This directory contains comprehensive test modules for Milvus client expression filtering capabilities.
## Test Modules
### 1. `test_milvus_client_scalar_expression_filtering_optimized.py`
**Primary test module for comprehensive scalar expression filtering**
**Features:**
- Tests all Milvus-supported scalar data types (INT8, INT16, INT32, INT64, BOOL, FLOAT, DOUBLE, VARCHAR, ARRAY, JSON)
- Covers all operators: Comparison (==, !=, >, <, >=, <=), Range (IN, LIKE), Arithmetic (+, -, *, /, %, **), Logical (AND, OR, NOT), Null (IS NULL, IS NOT NULL)
- Single collection design with multiple index types for efficiency
- Index consistency verification (same results for indexed vs non-indexed fields)
- Comprehensive error handling and failure debugging
- Automatic reproduction script generation
- Test complex Json expression (JSON[JSON], JSON[LIST[JSON]], JSON[JSON[LIST]], etc)
**Key Design:**
- One collection containing all data types
- Each data type has multiple fields representing different index types
- 10% of data is NULL to test IS NULL/IS NOT NULL operators
- Specific VARCHAR patterns: `str_xxx`, `xxx_str`, `xxx_str_xxx`
- Comprehensive LIKE pattern coverage with escape handling
- Create examples of typed, dynamic, and shared keys in json
- Generate expressions to valida query result
### 2. `test_milvus_client_scalar_expression_filtering.py`
**Legacy comprehensive scalar expression filtering test**
**Features:**
- Original comprehensive test implementation
- Multiple collection approach
- Extensive test coverage for all data types and operators
- Detailed validation logic
### 3. `test_milvus_client_random_expression_generator.py`
**Random expression generation for edge case testing**
**Features:**
- Generates random complex expressions
- Tests edge cases and unusual combinations
- Stress testing for expression parsing
- Random data generation with various patterns
## Data Type Coverage
### Supported Scalar Types
- **Numeric**: INT8, INT16, INT32, INT64, FLOAT, DOUBLE
- **Boolean**: BOOL
- **String**: VARCHAR
- **Array**: ARRAY (with all element types)
- **JSON**: JSON (with complex nested structures)
### Array Element Types
- All scalar types: INT8, INT16, INT32, INT64, BOOL, FLOAT, DOUBLE, VARCHAR
## Operator Coverage
### Comparison Operators
- `==`, `!=`, `>`, `<`, `>=`, `<=`
### Range Operators
- `IN` (with array indexing support)
- `LIKE` (with comprehensive pattern coverage)
### Arithmetic Operators
- `+`, `-`, `*`, `/`, `%`, `**`
### Logical Operators
- `AND`, `OR`, `NOT`
### Null Operators
- `IS NULL`, `IS NOT NULL`
### Array Functions
- Array indexing: `field[index]`
### JSON Functions
- JSON key access: `field['key']`
## Index Type Support
### Scalar Index Types
| Data Types | INVERTED | BITMAP | STL_SORT | Trie | NGRAM | AUTOINDEX |
|:---------------------------------------------------------|:--------:|:------:|:--------:|:----:|:-----:|:---------:|
| INT8, INT16, INT32, INT64 | yes | yes | yes | no | no | yes |
| BOOL | yes | yes | no | no | no | yes |
| FLOAT, DOUBLE | yes | no | yes | no | no | yes |
| VARCHAR | yes | yes | no | yes | yes | yes |
| JSON | yes | no | no | no | yes* | yes |
| ARRAY (elements: BOOL, INT8, INT16, INT32, INT64, VARCHAR) | yes | yes | no | no | no | yes |
| ARRAY (elements: FLOAT, DOUBLE) | yes | no | no | no | no | yes |
*JSON fields require `json_path` and `json_cast_type: "varchar"` parameters for NGRAM index
### NGRAM Index Specific Features
The NGRAM index is specialized for efficient text partial matching and fuzzy search on VARCHAR and JSON fields.
**Supported Fields:**
- **VARCHAR**: Direct text content indexing
- **JSON**: Requires `json_path` parameter to specify the JSON field path (e.g., `field_name['key']`)
**Index Parameters:**
- `min_gram`: Minimum n-gram length (required, positive integer)
- `max_gram`: Maximum n-gram length (required, positive integer, ≥ min_gram)
- `json_path`: JSON field path for JSON fields (e.g., `"json_field['body']"`)
- `json_cast_type`: Must be `"varchar"` for JSON fields
**Performance Characteristics:**
- Optimized for LIKE queries with `%` and `_` wildcards
- Two-phase query execution: n-gram filtering + secondary validation
- Query strings shorter than `min_gram` fall back to full table scan
- Supports multilingual text including Chinese, Japanese, and Korean
**Example Index Creation:**
```python
# VARCHAR field
index_params.add_index(
field_name="content",
index_type="NGRAM",
params={"min_gram": 2, "max_gram": 3}
)
# JSON field
index_params.add_index(
field_name="json_field",
index_type="NGRAM",
params={
"min_gram": 2,
"max_gram": 3,
"json_path": "json_field['body']",
"json_cast_type": "varchar"
}
)
```
## Test Features
### Error Handling
- Parsing error detection and skipping
- Graceful handling of unsupported expressions
- Detailed error reporting
### Debugging Support
- Automatic debug info saving on failure
- Parquet file export for test data
- Reproduction script generation
- Schema and configuration preservation
### Validation Logic
- Ground truth calculation using Python lambdas
- Result count and ID verification
- Index consistency verification
### LIKE Pattern Coverage
- Prefix patterns: `str%`
- Suffix patterns: `%str`
- Contains patterns: `%str%`
- Single character wildcard: `str_`, `_str`
- Combination patterns: `str_%`, `%_str`
- Escape patterns: `str\%`, `str\_`
**NGRAM Index Optimization:**
- LIKE queries on VARCHAR and JSON fields with NGRAM index are automatically optimized
- Query performance significantly improves for pattern matching operations
- Supports all LIKE patterns with `%` and `_` wildcards
- Automatic fallback to full scan when query length < `min_gram`
## Usage
### Running Tests
```bash
# Run optimized test
pytest test_milvus_client_scalar_expression_filtering_optimized.py
# Run legacy comprehensive test
pytest test_milvus_client_scalar_expression_filtering.py
# Run random expression generator
pytest test_milvus_client_random_expression_generator.py
# Run NGRAM index specific tests
pytest ../../testcases/indexes/test_ngram.py
```
### Debug Information
On test failure, debug information is automatically saved to `/tmp/ci_logs/`:
- Test data as Parquet files
- Collection schema and configuration
- Failed expressions list
- Reproduction script
### Reproduction Script
The generated reproduction script can:
- Rebuild the entire test environment
- Recreate schema, data, and indexes
- Re-run failed expressions
- Validate results
## Design Principles
1. **Comprehensive Coverage**: Test all supported data types, operators, and index types (including NGRAM)
2. **Efficiency**: Single collection design for optimal performance
3. **Reliability**: Robust error handling and debugging
4. **Maintainability**: Clear code structure and documentation
5. **Reproducibility**: Automatic failure reproduction capabilities
6. **Index Optimization**: Validate performance improvements with specialized indexes like NGRAM
@@ -0,0 +1,652 @@
"""
JSON filtering regression tests for SQL-style UNKNOWN semantics.
These cases use fixed expected IDs instead of only comparing raw and indexed
results, because raw and index paths can otherwise be wrong in the same way.
"""
import pytest
from base.client_v2_base import TestMilvusClientV2Base
from common import common_func as cf
from common.common_type import CaseLabel, CheckTasks
from pymilvus import DataType
default_dim = 8
default_pk = "id"
default_vec = "vector"
json_field = "json_field"
array_field = "array_field"
indexed_total_rows = 3000
def _vector(i):
value = float((i % 7) + 1) / 10.0
return [value] * default_dim
def _base_rows():
payloads = [
None,
{},
{"a": None},
{"a": "bad"},
{"a": 2},
{"a": 3},
{"arr": []},
{"arr": [1, 2]},
{"arr": "bad"},
{"nested": {"age": 30}},
{"nested": {}},
{"s": "abc"},
{"s": "def"},
{"s": 123},
{"s": None},
{"a": [2]},
{"nested": {"age": 40}},
{"nested": {"age": "bad"}},
{"nested": None},
{"b": True},
{"b": False},
{"b": None},
{"b": "true"},
{"f": 1.5},
{"f": -2.25},
{"f": 0.0},
{"f": "bad"},
{"path_arr": [3, "bad", None]},
{"op": 10},
{"op": 20},
{"op": None},
{"op": "bad"},
{"mixed": [1, "bad", None]},
{"path_arr": [1, 2]},
{"path_arr": []},
{"path_arr": "bad"},
]
arrays = [
None,
[],
None,
None,
[2],
[3],
[],
None,
None,
None,
[],
None,
None,
None,
[],
None,
None,
None,
None,
]
arrays.extend([None] * (len(payloads) - len(arrays)))
return [
{default_pk: i, default_vec: _vector(i), json_field: payload, array_field: arrays[i]}
for i, payload in enumerate(payloads)
]
def _rows(total=20):
rows = _base_rows()
for i in range(len(rows), total):
rows.append({default_pk: i, default_vec: _vector(i), json_field: {"pad": i}, array_field: None})
return rows
def _hit_id(hit):
if default_pk in hit:
return hit[default_pk]
entity = hit.get("entity", {})
if default_pk in entity:
return entity[default_pk]
return hit["id"]
class JsonFilteringUnknownMixin:
def _collection_name(self, *parts):
name = "_".join([self.__class__.__name__[:40], *parts])
return cf.gen_unique_str(name)
def _create_schema(self, client):
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(default_pk, DataType.INT64, is_primary=True, auto_id=False)
schema.add_field(default_vec, DataType.FLOAT_VECTOR, dim=default_dim)
schema.add_field(json_field, DataType.JSON, nullable=True)
schema.add_field(array_field, DataType.ARRAY, element_type=DataType.INT64, max_capacity=8, nullable=True)
return schema
def _create_collection(self, client, collection_name, segment_mode, total_rows=20):
schema = self._create_schema(client)
self.create_collection(client, collection_name, schema=schema)
if segment_mode == "growing":
index_params = self.prepare_index_params(client)[0]
index_params.add_index(default_vec, index_type="FLAT", metric_type="COSINE")
self.create_index(client, collection_name, index_params=index_params)
self.load_collection(client, collection_name)
self.insert(client, collection_name, _rows(total_rows))
return
self.insert(client, collection_name, _rows(total_rows))
self.flush(client, collection_name)
index_params = self.prepare_index_params(client)[0]
index_params.add_index(default_vec, index_type="FLAT", metric_type="COSINE")
self.create_index(client, collection_name, index_params=index_params)
self.load_collection(client, collection_name)
def _query_ids(self, client, collection_name, expr):
res = self.query(
client,
collection_name,
filter=expr,
output_fields=[default_pk],
consistency_level="Strong",
check_task=CheckTasks.check_nothing,
)[0]
assert not hasattr(res, "message"), f"query failed for {expr}: {getattr(res, 'message', res)}"
return sorted(row[default_pk] for row in res)
def _assert_query_cases(self, client, collection_name, cases):
for expr, expected in cases:
assert self._query_ids(client, collection_name, expr) == expected, expr
class TestJsonFilteringUnknownSemantics(JsonFilteringUnknownMixin, TestMilvusClientV2Base):
"""
JSON UNKNOWN semantics on raw filtering paths.
When to add tests here:
- The test has exact expected IDs.
- The test does not require a JSON scalar/path index.
- The test should run against both growing and sealed segment states.
"""
@pytest.mark.tags(CaseLabel.L0)
@pytest.mark.parametrize("segment_mode", ["growing", "sealed"])
def test_numeric_missing_path_unknown_query(self, segment_mode):
"""
target: missing/null/cast-fail JSON scalar values are UNKNOWN
method: run positive and negative numeric JSON path predicates
expected: only comparable known values can match, even for !=/not in/not
"""
client = self._client()
collection_name = self._collection_name(segment_mode, "numeric")
self._create_collection(client, collection_name, segment_mode)
self._assert_query_cases(
client,
collection_name,
[
(f'{json_field}["a"] == 2', [4]),
(f'{json_field}["a"] > 2', [5]),
(f'{json_field}["a"] in [2, 3]', [4, 5]),
(f'{json_field}["a"] != 2', [5]),
(f'{json_field}["a"] not in [2]', [5]),
(f'not ({json_field}["a"] > 2)', [4]),
],
)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("segment_mode", ["growing", "sealed"])
def test_json_arithmetic_missing_operand_unknown_query(self, segment_mode):
"""
target: JSON arithmetic path extraction failures are UNKNOWN
method: run arithmetic predicates over missing/null/cast-fail JSON paths
expected: UNKNOWN rows do not match != or outer NOT
"""
client = self._client()
collection_name = self._collection_name(segment_mode, "arith")
self._create_collection(client, collection_name, segment_mode)
self._assert_query_cases(
client,
collection_name,
[
(f'{json_field}["a"] + 1 != 3', [5]),
(f'not ({json_field}["a"] + 1 > 3)', [4]),
(f'{json_field}["a"] * 2 == 4', [4]),
],
)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("segment_mode", ["growing", "sealed"])
def test_json_array_missing_path_unknown_query(self, segment_mode):
"""
target: missing/non-array JSON array paths are UNKNOWN
method: run array_length and json_contains predicates
expected: only real arrays participate in array predicates
"""
client = self._client()
collection_name = self._collection_name(segment_mode, "array")
self._create_collection(client, collection_name, segment_mode)
self._assert_query_cases(
client,
collection_name,
[
(f'array_length({json_field}["arr"]) == 0', [6]),
(f'array_length({json_field}["arr"]) != 0', [7]),
(f'not (array_length({json_field}["arr"]) > 0)', [6]),
(f'json_contains({json_field}["arr"], 1)', [7]),
(f'json_contains_any({json_field}["arr"], [1, 9])', [7]),
(f'json_contains_all({json_field}["arr"], [1, 2])', [7]),
],
)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("segment_mode", ["growing", "sealed"])
def test_varchar_missing_path_unknown_query(self, segment_mode):
"""
target: VARCHAR JSON path missing/null/cast-fail values are UNKNOWN
method: run equality and negative term predicates over string path
expected: only comparable known string values can match
"""
client = self._client()
collection_name = self._collection_name(segment_mode, "varchar")
self._create_collection(client, collection_name, segment_mode)
self._assert_query_cases(
client,
collection_name,
[
(f'{json_field}["s"] == "abc"', [11]),
(f'{json_field}["s"] != "abc"', [12]),
(f'{json_field}["s"] in ["abc", "def"]', [11, 12]),
(f'{json_field}["s"] not in ["abc"]', [12]),
],
)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("segment_mode", ["growing", "sealed"])
def test_nested_missing_leaf_unknown_query(self, segment_mode):
"""
target: missing nested JSON leaf values are UNKNOWN
method: run comparison and NOT predicates over a nested JSON path
expected: only comparable known nested values can match
"""
client = self._client()
collection_name = self._collection_name(segment_mode, "nested")
self._create_collection(client, collection_name, segment_mode)
self._assert_query_cases(
client,
collection_name,
[
(f'{json_field}["nested"]["age"] == 30', [9]),
(f'{json_field}["nested"]["age"] > 30', [16]),
(f'{json_field}["nested"]["age"] != 30', [16]),
(f'not ({json_field}["nested"]["age"] > 30)', [9]),
],
)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("segment_mode", ["growing", "sealed"])
def test_array_subscript_unknown_query(self, segment_mode):
"""
target: ARRAY subscript missing/out-of-range values are UNKNOWN
method: run direct array subscript predicates and outer NOT over empty/null arrays
expected: only rows with existing comparable array elements match
"""
client = self._client()
collection_name = self._collection_name(segment_mode, "array_subscript")
self._create_collection(client, collection_name, segment_mode)
self._assert_query_cases(
client,
collection_name,
[
(f"{array_field}[0] == 2", [4]),
(f"{array_field}[0] > 2", [5]),
(f"{array_field}[0] != 2", [5]),
(f"not ({array_field}[0] > 2)", [4]),
],
)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("segment_mode", ["growing", "sealed"])
def test_contradiction_rewrite_keeps_unknown_query(self, segment_mode):
"""
target: contradiction rewrites do not turn UNKNOWN JSON/array extraction into TRUE under outer NOT
method: run impossible ranges and their outer NOT over JSON paths and array subscripts
expected: missing/null/type-mismatch rows remain UNKNOWN and do not match
"""
client = self._client()
collection_name = self._collection_name(segment_mode, "rewrite")
self._create_collection(client, collection_name, segment_mode)
self._assert_query_cases(
client,
collection_name,
[
(f'{json_field}["a"] > 100 and {json_field}["a"] < 50', []),
(f'not ({json_field}["a"] > 100 and {json_field}["a"] < 50)', [4, 5]),
(f"{array_field}[0] > 100 and {array_field}[0] < 50", []),
(f"not ({array_field}[0] > 100 and {array_field}[0] < 50)", [4, 5]),
],
)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("segment_mode", ["growing", "sealed"])
def test_l2_boolean_and_fractional_values_query(self, segment_mode):
"""
target: JSON boolean and fractional double values keep UNKNOWN semantics
method: run bool and fractional numeric predicates over missing/null/type-mismatch JSON paths
expected: only rows with comparable bool/double values match
"""
client = self._client()
collection_name = self._collection_name(segment_mode, "l2_bool_float")
self._create_collection(client, collection_name, segment_mode)
self._assert_query_cases(
client,
collection_name,
[
(f'{json_field}["b"] == true', [19]),
(f'{json_field}["b"] == false', [20]),
(f'{json_field}["b"] != true', [20]),
(f'not ({json_field}["b"] == true)', [20]),
(f'{json_field}["f"] > 1.0', [23]),
(f'{json_field}["f"] < 0', [24]),
(f'{json_field}["f"] >= 0', [23, 25]),
(f'not ({json_field}["f"] <= 0)', [23]),
],
)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("segment_mode", ["growing", "sealed"])
def test_l2_operator_matrix_query(self, segment_mode):
"""
target: additional comparison and logical operators preserve UNKNOWN semantics
method: run <, <=, >=, or, and, and negated equality over a numeric JSON path
expected: null/missing/type-mismatch rows do not match positive or outer NOT predicates
"""
client = self._client()
collection_name = self._collection_name(segment_mode, "l2_ops")
self._create_collection(client, collection_name, segment_mode)
self._assert_query_cases(
client,
collection_name,
[
(f'{json_field}["op"] < 20', [28]),
(f'{json_field}["op"] <= 10', [28]),
(f'{json_field}["op"] >= 20', [29]),
(f'not ({json_field}["op"] == 10)', [29]),
(f'{json_field}["op"] == 10 or {json_field}["op"] == 20', [28, 29]),
(f'{json_field}["op"] > 5 and {json_field}["op"] < 15', [28]),
(f'not ({json_field}["op"] < 20 or {json_field}["op"] > 30)', [29]),
],
)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("segment_mode", ["growing", "sealed"])
def test_l2_json_path_array_subscript_query(self, segment_mode):
"""
target: JSON path array subscripts keep UNKNOWN semantics
method: run numeric and string comparisons over JSON array elements
expected: empty arrays, non-arrays, missing paths, and type mismatches do not match
"""
client = self._client()
collection_name = self._collection_name(segment_mode, "l2_json_array_subscript")
self._create_collection(client, collection_name, segment_mode)
self._assert_query_cases(
client,
collection_name,
[
(f'{json_field}["path_arr"][0] == 1', [33]),
(f'{json_field}["path_arr"][0] > 1', [27]),
(f'{json_field}["path_arr"][1] == 2', [33]),
(f'{json_field}["path_arr"][1] == "bad"', [27]),
(f'not ({json_field}["path_arr"][0] > 1)', [33]),
],
)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("segment_mode", ["growing", "sealed"])
def test_l2_mixed_array_contains_query(self, segment_mode):
"""
target: JSON array predicates handle mixed-type arrays without matching missing paths
method: run contains predicates over an array with numeric, string, and null elements
expected: only the row containing the requested numeric element matches
"""
client = self._client()
collection_name = self._collection_name(segment_mode, "l2_mixed_array")
self._create_collection(client, collection_name, segment_mode)
self._assert_query_cases(
client,
collection_name,
[
(f'json_contains({json_field}["mixed"], 1)', [32]),
(f'json_contains_any({json_field}["mixed"], [1, 9])', [32]),
(f'json_contains_all({json_field}["mixed"], [1])', [32]),
],
)
class TestJsonFilteringIndexedUnknownSemantics(JsonFilteringUnknownMixin, TestMilvusClientV2Base):
"""
JSON UNKNOWN semantics across no-index, JSON path index, query, and search.
When to add tests here:
- The test requires JSON path index or JSON flat index coverage.
- The first phase must cover DOUBLE and VARCHAR path-index cast types.
- ARRAY_* cast types are L2 and should be added with dedicated array-path
UNKNOWN assertions.
"""
def _create_sealed_collection(self, client, collection_name, json_indexes=None, total_rows=indexed_total_rows):
schema = self._create_schema(client)
self.create_collection(client, collection_name, schema=schema)
self.insert(client, collection_name, _rows(total_rows))
self.flush(client, collection_name)
index_params = self.prepare_index_params(client)[0]
index_params.add_index(default_vec, index_type="FLAT", metric_type="COSINE")
for index in json_indexes or []:
index_params.add_index(field_name=json_field, **index)
self.create_index(client, collection_name, index_params=index_params)
self.load_collection(client, collection_name)
def _search_ids(self, client, collection_name, expr, limit=indexed_total_rows):
res = self.search(
client,
collection_name,
data=[_vector(0)],
anns_field=default_vec,
filter=expr,
limit=limit,
output_fields=[default_pk],
search_params={"metric_type": "COSINE", "params": {}},
consistency_level="Strong",
check_task=CheckTasks.check_nothing,
)[0]
assert not hasattr(res, "message"), f"search failed for {expr}: {getattr(res, 'message', res)}"
return sorted(_hit_id(hit) for hit in res[0])
def _assert_query_and_search_cases(self, client, collection_name, cases):
for expr, expected in cases:
assert self._query_ids(client, collection_name, expr) == expected, expr
assert self._search_ids(client, collection_name, expr) == expected, expr
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize(
"index_name,json_indexes,cases",
[
(
"inverted_double",
[
{
"index_name": "idx_json_a_inverted_double",
"index_type": "INVERTED",
"params": {"json_cast_type": "DOUBLE", "json_path": f"{json_field}['a']"},
}
],
[
(f'{json_field}["a"] == 2', [4]),
(f'{json_field}["a"] > 2', [5]),
(f'{json_field}["a"] != 2', [5]),
(f'{json_field}["a"] not in [2]', [5]),
(f'not ({json_field}["a"] > 2)', [4]),
],
),
(
"stl_sort_double",
[
{
"index_name": "idx_json_a_stl_sort_double",
"index_type": "STL_SORT",
"params": {"json_cast_type": "DOUBLE", "json_path": f"{json_field}['a']"},
}
],
[
(f'{json_field}["a"] == 2', [4]),
(f'{json_field}["a"] > 2', [5]),
(f'{json_field}["a"] != 2', [5]),
(f'{json_field}["a"] not in [2]', [5]),
(f'not ({json_field}["a"] > 2)', [4]),
],
),
(
"inverted_varchar",
[
{
"index_name": "idx_json_s_inverted_varchar",
"index_type": "INVERTED",
"params": {"json_cast_type": "VARCHAR", "json_path": f"{json_field}['s']"},
}
],
[
(f'{json_field}["s"] == "abc"', [11]),
(f'{json_field}["s"] != "abc"', [12]),
(f'{json_field}["s"] in ["abc", "def"]', [11, 12]),
(f'{json_field}["s"] not in ["abc"]', [12]),
],
),
],
)
def test_json_path_index_unknown_semantics_query(self, index_name, json_indexes, cases):
"""
target: JSON path indexes preserve UNKNOWN semantics
method: compare no-index and indexed sealed collections with 3000 rows against fixed expected IDs
expected: indexed and no-index results both match expected IDs
"""
client = self._client()
raw_collection = self._collection_name(index_name, "raw")
indexed_collection = self._collection_name(index_name, "idx")
self._create_sealed_collection(client, raw_collection)
self._create_sealed_collection(client, indexed_collection, json_indexes=json_indexes)
self._assert_query_and_search_cases(client, raw_collection, cases)
self._assert_query_and_search_cases(client, indexed_collection, cases)
@pytest.mark.tags(CaseLabel.L1)
def test_json_flat_index_unknown_semantics_query(self):
"""
target: JSON flat index preserves UNKNOWN semantics for comparable values
method: compare no-index and json_cast_type=json flat-index collections with 3000 rows
expected: flat-index and no-index results both match fixed expected IDs
"""
client = self._client()
raw_collection = self._collection_name("flat_raw")
indexed_collection = self._collection_name("flat_idx")
json_indexes = [
{
"index_name": "idx_json_flat",
"index_type": "INVERTED",
"params": {"json_cast_type": "json"},
}
]
cases = [
(f'{json_field}["a"] != 2', [5]),
(f'{json_field}["a"] not in [2]', [5]),
(f'{json_field}["s"] != "abc"', [12]),
(f'{json_field}["s"] not in ["abc"]', [12]),
(f'{json_field}["nested"]["age"] != 30', [16]),
]
self._create_sealed_collection(client, raw_collection)
self._create_sealed_collection(client, indexed_collection, json_indexes=json_indexes)
self._assert_query_and_search_cases(client, raw_collection, cases)
self._assert_query_and_search_cases(client, indexed_collection, cases)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.skip(reason="Known JSON flat index UNKNOWN mismatch: https://github.com/milvus-io/milvus/issues/51193")
def test_json_flat_index_array_scalar_comparison_unknown_semantics_query(self):
"""
target: JSON flat index should preserve UNKNOWN for array-vs-scalar comparisons
method: compare no-index and json_cast_type=json flat-index collections with 3000 rows
expected: {"a": [2]} is UNKNOWN for scalar comparison and does not match outer NOT
"""
client = self._client()
raw_collection = self._collection_name("flat_array_raw")
indexed_collection = self._collection_name("flat_array_idx")
json_indexes = [
{
"index_name": "idx_json_flat",
"index_type": "INVERTED",
"params": {"json_cast_type": "json"},
}
]
cases = [(f'not ({json_field}["a"] > 2)', [4])]
self._create_sealed_collection(client, raw_collection)
self._create_sealed_collection(client, indexed_collection, json_indexes=json_indexes)
self._assert_query_cases(client, raw_collection, cases)
self._assert_query_cases(client, indexed_collection, cases)
@pytest.mark.tags(CaseLabel.L1)
def test_query_and_search_filter_consistency(self):
"""
target: query and search filter consumers exclude UNKNOWN rows
method: run representative JSON filters through both APIs on a 3000-row sealed collection
expected: both APIs return the same fixed expected IDs
"""
client = self._client()
collection_name = self._collection_name("search_query")
self._create_sealed_collection(client, collection_name)
cases = [
(f'{json_field}["a"] != 2', [5]),
(f'{json_field}["a"] not in [2]', [5]),
(f'not ({json_field}["a"] > 2)', [4]),
(f'array_length({json_field}["arr"]) == 0', [6]),
(f'json_contains({json_field}["arr"], 1)', [7]),
]
for expr, expected in cases:
assert self._query_ids(client, collection_name, expr) == expected, expr
assert self._search_ids(client, collection_name, expr) == expected, expr
@pytest.mark.tags(CaseLabel.L2)
def test_l2_json_array_path_index_unknown_semantics_query(self):
"""
target: ARRAY_DOUBLE JSON path indexes preserve UNKNOWN semantics
method: compare no-index and ARRAY_DOUBLE indexed sealed collections with fixed expected IDs
expected: query and search results match expected IDs on both raw and indexed collections
"""
client = self._client()
raw_collection = self._collection_name("l2_array_path_raw")
indexed_collection = self._collection_name("l2_array_path_idx")
json_indexes = [
{
"index_name": "idx_json_arr_array_double",
"index_type": "INVERTED",
"params": {"json_cast_type": "ARRAY_DOUBLE", "json_path": f"{json_field}['arr']"},
}
]
cases = [
(f'json_contains({json_field}["arr"], 1)', [7]),
(f'json_contains_any({json_field}["arr"], [1, 9])', [7]),
(f'json_contains_all({json_field}["arr"], [1, 2])', [7]),
]
self._create_sealed_collection(client, raw_collection)
self._create_sealed_collection(client, indexed_collection, json_indexes=json_indexes)
self._assert_query_and_search_cases(client, raw_collection, cases)
self._assert_query_and_search_cases(client, indexed_collection, cases)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,935 @@
import pytest
from pymilvus import DataType
from base.client_v2_base import TestMilvusClientV2Base
from utils.util_log import test_log as log
from common import common_func as cf
from common import common_type as ct
from common.common_type import CaseLabel, CheckTasks
from utils.util_pymilvus import *
prefix = "client_alias"
epsilon = ct.epsilon
default_nb = ct.default_nb
default_nb_medium = ct.default_nb_medium
default_nq = ct.default_nq
default_dim = ct.default_dim
default_limit = ct.default_limit
default_search_exp = "id >= 0"
exp_res = "exp_res"
default_search_string_exp = "varchar >= \"0\""
default_search_mix_exp = "int64 >= 0 && varchar >= \"0\""
default_invaild_string_exp = "varchar >= 0"
default_json_search_exp = "json_field[\"number\"] >= 0"
perfix_expr = 'varchar like "0%"'
default_search_field = ct.default_float_vec_field_name
default_search_params = ct.default_search_params
default_primary_key_field_name = "id"
default_vector_field_name = "vector"
default_float_field_name = ct.default_float_field_name
default_bool_field_name = ct.default_bool_field_name
default_string_field_name = ct.default_string_field_name
default_int32_array_field_name = ct.default_int32_array_field_name
default_string_array_field_name = ct.default_string_array_field_name
default_schema = cf.gen_default_collection_schema()
default_binary_schema = cf.gen_default_binary_collection_schema()
class TestMilvusClientAliasInvalid(TestMilvusClientV2Base):
""" Test case of search interface """
@pytest.fixture(scope="function", params=[False, True])
def auto_id(self, request):
yield request.param
@pytest.fixture(scope="function", params=["COSINE", "L2"])
def metric_type(self, request):
yield request.param
"""
******************************************************************
# The following are invalid base cases
******************************************************************
"""
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("collection_name", ["12-s", "12 s", "(mn)", "中文", "%$#"])
def test_milvus_client_create_alias_invalid_collection_name(self, collection_name):
"""
target: test alias (high level api) normal case
method: create connection, collection, partition, alias, and assert collection
is equal to alias according to partitions
expected: create alias successfully
"""
client = self._client()
alias = cf.gen_unique_str("collection_alias")
# 2. create alias
error = {ct.err_code: 1100, ct.err_msg: f"Invalid collection name: {collection_name}. the first character of a "
f"collection name must be an underscore or letter: invalid parameter"}
self.create_alias(client, collection_name, alias,
check_task=CheckTasks.err_res, check_items=error)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_create_alias_collection_name_over_max_length(self):
"""
target: test alias (high level api) normal case
method: create connection, collection, partition, alias, and assert collection
is equal to alias according to partitions
expected: create alias successfully
"""
client = self._client()
alias = cf.gen_unique_str("collection_alias")
collection_name = "a".join("a" for i in range(256))
# 2. create alias
error = {ct.err_code: 1100, ct.err_msg: f"the length of a collection name must be less than 255 characters"}
self.create_alias(client, collection_name, alias,
check_task=CheckTasks.err_res, check_items=error)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_create_alias_name_over_max_length(self):
"""
target: test alias (high level api) normal case
method: create connection, collection, partition, alias, and assert collection
is equal to alias according to partitions
expected: create alias successfully
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
alias = "a".join("a" for i in range(256))
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Strong")
# 2. create alias
error = {ct.err_code: 1100, ct.err_msg: f"the length of a collection alias must be less than 255 characters"}
self.create_alias(client, collection_name, alias,
check_task=CheckTasks.err_res, check_items=error)
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_create_alias_same_collection_name(self):
"""
target: test alias (high level api) normal case
method: create connection, collection, partition, alias, and assert collection
is equal to alias according to partitions
expected: create alias successfully
"""
client = self._client()
collection_name = cf.gen_unique_str('coll')
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Strong")
# 2. create alias
error = {ct.err_code: 1601,
ct.err_msg: f"alias and collection name conflict[database=default][alias={collection_name}]"}
self.create_alias(client, collection_name, collection_name,
check_task=CheckTasks.err_res, check_items=error)
# create a collection with the same alias name
alias_name = cf.gen_unique_str('alias')
self.create_alias(client, collection_name, alias_name)
error = {ct.err_code: 1601,
ct.err_msg: f"alias and collection name conflict[database=default][alias={alias_name}]"}
self.create_collection(client, alias_name, default_dim,
check_task=CheckTasks.err_res, check_items=error)
self.drop_alias(client, alias_name)
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("alias_name", ["12-s", "12 s", "(mn)", "中文", "%$#"])
@pytest.mark.skip(reason="https://github.com/milvus-io/milvus/pull/43064 change drop alias restraint")
def test_milvus_client_drop_alias_invalid_alias_name(self, alias_name):
"""
target: test create same alias to different collections
method: create connection, collection, partition, alias, and assert collection
is equal to alias according to partitions
expected: create alias successfully
"""
client = self._client()
error = {ct.err_code: 1100, ct.err_msg: f"Invalid collection alias: {alias_name}. the first character of a "
f"collection alias must be an underscore or letter"}
self.drop_alias(client, alias_name,
check_task=CheckTasks.err_res, check_items=error)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.skip(reason="https://github.com/milvus-io/milvus/pull/43064 change drop alias restraint")
def test_milvus_client_drop_alias_over_max_length(self):
"""
target: test create same alias to different collections
method: create connection, collection, partition, alias, and assert collection
is equal to alias according to partitions
expected: create alias successfully
"""
client = self._client()
alias = "a".join("a" for i in range(256))
error = {ct.err_code: 1100, ct.err_msg: f"the length of a collection alias must be less than 255 characters"}
self.drop_alias(client, alias,
check_task=CheckTasks.err_res, check_items=error)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("collection_name", ["12-s", "12 s", "(mn)", "中文", "%$#"])
def test_milvus_client_alter_alias_invalid_collection_name(self, collection_name):
"""
target: test alias (high level api) normal case
method: create connection, collection, partition, alias, and assert collection
is equal to alias according to partitions
expected: create alias successfully
"""
client = self._client()
alias = cf.gen_unique_str("collection_alias")
error = {ct.err_code: 1100, ct.err_msg: f"Invalid collection name: {collection_name}. the first character of a "
f"collection name must be an underscore or letter: invalid parameter"}
self.alter_alias(client, collection_name, alias,
check_task=CheckTasks.err_res, check_items=error)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_alter_alias_collection_name_over_max_length(self):
"""
target: test alias (high level api) normal case
method: create connection, collection, partition, alias, and assert collection
is equal to alias according to partitions
expected: create alias successfully
"""
client = self._client()
alias = cf.gen_unique_str("collection_alias")
collection_name = "a".join("a" for i in range(256))
# 2. create alias
error = {ct.err_code: 1100, ct.err_msg: f"the length of a collection name must be less than 255 characters"}
self.alter_alias(client, collection_name, alias,
check_task=CheckTasks.err_res, check_items=error)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_alter_alias_invalid_alias_name(self):
"""
target: test alias (high level api) normal case
method: create connection, collection, partition, alias, and assert collection
is equal to alias according to partitions
expected: create alias successfully
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Strong")
# 2. create alias
alias_names = ["12-s", "12 s", "(mn)", "中文", "%$#"]
for alias in alias_names:
error = {ct.err_code: 1100, ct.err_msg: f"Invalid collection alias: {alias}. the first character of a "
f"collection alias must be an underscore or letter"}
self.alter_alias(client, collection_name, alias,
check_task=CheckTasks.err_res, check_items=error)
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_alter_alias_name_over_max_length(self):
"""
target: test alias (high level api) normal case
method: create connection, collection, partition, alias, and assert collection
is equal to alias according to partitions
expected: create alias successfully
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
alias = "a".join("a" for i in range(256))
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Strong")
# 2. create alias
error = {ct.err_code: 1100, ct.err_msg: f"the length of a collection alias must be less than 255 characters"}
self.alter_alias(client, collection_name, alias,
check_task=CheckTasks.err_res, check_items=error)
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_alter_alias_same_collection_name(self):
"""
target: test alias (high level api) normal case
method: create connection, collection, partition, alias, and assert collection
is equal to alias according to partitions
expected: create alias successfully
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Strong")
# 2. create alias
error = {ct.err_code: 1601, ct.err_msg: f"alias and collection name conflict[database=default]"
f"[alias={collection_name}"}
self.alter_alias(client, collection_name, collection_name,
check_task=CheckTasks.err_res, check_items=error)
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("alias_name", ct.invalid_resource_names)
def test_milvus_client_create_alias_with_invalid_name(self, alias_name):
"""
target: test creating alias with invalid name is rejected
method: create a collection, then create alias with invalid name
expected: create alias failed with error
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Bounded")
# 2. create alias with invalid name
error = {ct.err_code: 1100, ct.err_msg: "Invalid collection alias"}
if alias_name is None or alias_name == "":
error = {ct.err_code: 1100, ct.err_msg: "collection alias should not be empty"}
self.create_alias(client, collection_name, alias_name,
check_task=CheckTasks.err_res, check_items=error)
# cleanup
self.drop_collection(client, collection_name)
class TestMilvusClientAliasValid(TestMilvusClientV2Base):
""" Test case of search interface """
@pytest.fixture(scope="function", params=[False, True])
def auto_id(self, request):
yield request.param
@pytest.fixture(scope="function", params=["COSINE", "L2"])
def metric_type(self, request):
yield request.param
"""
******************************************************************
# The following are valid base cases
******************************************************************
"""
@pytest.mark.tags(CaseLabel.L2)
def test_milvus_client_alias_search_query(self):
"""
target: test search (high level api) normal case
method: create connection, collection, insert and search
expected: search/query successfully
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
alias = "collection_alias"
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Strong")
# 2. create alias
self.drop_alias(client, alias)
self.create_alias(client, collection_name, alias)
collection_name = alias
# 2. insert
rng = np.random.default_rng(seed=19530)
rows = [{default_primary_key_field_name: i, default_vector_field_name: list(rng.random((1, default_dim))[0]),
default_float_field_name: i * 1.0, default_string_field_name: str(i)} for i in range(default_nb)]
self.insert(client, collection_name, rows)
# self.flush(client, collection_name)
# assert self.num_entities(client, collection_name)[0] == default_nb
# 3. search
vectors_to_search = rng.random((1, default_dim))
insert_ids = [i for i in range(default_nb)]
self.search(client, collection_name, vectors_to_search,
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": len(vectors_to_search),
"ids": insert_ids,
"limit": default_limit,
"pk_name": default_primary_key_field_name})
# 4. query
self.query(client, collection_name, filter=default_search_exp,
check_task=CheckTasks.check_query_results,
check_items={exp_res: rows,
"with_vec": True,
"pk_name": default_primary_key_field_name})
self.release_collection(client, collection_name)
self.drop_collection(client, collection_name, check_task=CheckTasks.err_res,
check_items={ct.err_code: 65535,
ct.err_msg: "cannot drop the collection via alias = collection_alias"})
self.drop_alias(client, alias)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.skip(reason="pymilvus issue 1891, 1892")
def test_milvus_client_alias_default(self):
"""
target: test alias (high level api) normal case
method: create connection, collection, partition, alias, and assert collection
is equal to alias according to partitions
expected: create alias successfully
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
partition_name = cf.gen_unique_str("partition")
alias = cf.gen_unique_str("collection_alias")
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Strong")
self.create_partition(client, collection_name, partition_name)
partition_name_list = self.list_partitions(client, collection_name)[0]
# 2. create alias
self.create_alias(client, collection_name, alias)
self.describe_alias(client, alias)
# 3. list alias
aliases = self.list_aliases(client)[0]
# assert alias in aliases
# 4. assert collection is equal to alias according to partitions
partition_name_list_alias = self.list_partitions(client, alias)[0]
assert partition_name_list == partition_name_list_alias
# 5. drop alias
self.drop_alias(client, alias)
aliases = self.list_aliases(client)[0]
# assert alias not in aliases
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_alter_alias_default(self):
"""
target: test alter alias (high level api)
method: create connection, collection, partition, alias, and assert collection
is equal to alias according to partitions
expected: alter alias successfully
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
another_collectinon_name = cf.gen_unique_str(prefix)
partition_name = cf.gen_unique_str("partition")
alias = cf.gen_unique_str("collection_alias")
another_alias = cf.gen_unique_str("collection_alias_another")
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Strong")
self.create_partition(client, collection_name, partition_name)
partition_name_list = self.list_partitions(client, collection_name)[0]
self.create_collection(client, another_collectinon_name, default_dim, consistency_level="Strong")
self.create_alias(client, another_collectinon_name, another_alias)
# 2. create alias
self.create_alias(client, collection_name, alias)
# 3. alter alias
self.alter_alias(client, collection_name, another_alias)
self.describe_alias(client, alias)
# 3. list alias
aliases = self.list_aliases(client, collection_name)[0]
# assert alias in aliases
# assert another_alias in aliases
# 4. assert collection is equal to alias according to partitions
partition_name_list_alias = self.list_partitions(client, another_alias)[0]
assert partition_name_list == partition_name_list_alias
self.drop_alias(client, alias)
self.drop_alias(client, another_alias)
self.drop_collection(client, collection_name)
class TestMilvusClientAliasOperation(TestMilvusClientV2Base):
""" This is a migration test case for alias operation """
@pytest.fixture(scope="function", params=[False, True])
def auto_id(self, request):
yield request.param
@pytest.fixture(scope="function", params=["COSINE", "L2"])
def metric_type(self, request):
yield request.param
@pytest.mark.tags(CaseLabel.L2)
def test_milvus_client_alias_enable_mmap_by_alias(self):
"""
target: test utility enable mmap by alias
method:
1.create collection with alias
2.call enable_mmap function with alias as param
expected: result is True
"""
# step 1: create collection with alias
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name(module_index=1)
self.create_collection(client, collection_name, default_dim, consistency_level="Strong")
alias = cf.gen_unique_str("collection_alias")
self.create_alias(client, collection_name, alias)
# step 2: enable mmap via alias and verify
self.release_collection(client, collection_name)
self.alter_collection_properties(client, alias, properties={"mmap.enabled": True})
res = self.describe_collection(client, collection_name)[0].get("properties")
assert res["mmap.enabled"] == 'True'
self.drop_alias(client, alias)
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L0)
def test_milvus_client_alter_alias_operation_default(self):
"""
target: test collection altering alias
method:
1. create collection_1 with index and load, bind alias to collection_1 and insert 2000 entities
2. verify count and search using alias work on collection_1
3. create collection_2 with index and load with 1500 entities (start=10000 to distinguish IDs)
4. alter alias to collection_2
5. verify count and search using alias work on collection_2 (IDs in collection_2 range)
6. verify collection_1 still has its own data
expected:
1. operations using alias work on collection_1 before alter
2. operations using alias work on collection_2 after alter
3. collection_1 data is unaffected
"""
client = self._client()
# 1. create collection1 with schema, index and load
collection_name1 = cf.gen_collection_name_by_testcase_name()
schema1 = self.create_schema(client)[0]
schema1.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True)
schema1.add_field(ct.default_float_field_name, DataType.FLOAT)
schema1.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=256)
schema1.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
index_params = self.prepare_index_params(client)[0]
index_params.add_index(field_name=ct.default_float_vec_field_name, metric_type="L2")
self.create_collection(client, collection_name1, schema=schema1,
index_params=index_params, consistency_level="Bounded")
# 2. create alias and insert data into collection1 via alias
alias_name = cf.gen_unique_str(prefix)
self.create_alias(client, collection_name1, alias_name)
nb1 = 2000
data1 = cf.gen_row_data_by_schema(nb=nb1, schema=schema1, start=0)
self.insert(client, alias_name, data1)
self.flush(client, alias_name)
# 3. verify collection1 count using alias
res1 = self.query(client, alias_name, filter=f"{ct.default_int64_field_name} >= 0",
output_fields=["count(*)"])
assert res1[0][0].get("count(*)") == nb1
# 4. verify search using alias works on collection1
search_vectors = cf.gen_vectors(1, default_dim)
self.search(client, alias_name, search_vectors, limit=default_limit,
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": 1,
"pk_name": ct.default_int64_field_name,
"limit": default_limit,
"metric": "L2"})
# 5. create collection2 with same schema, index and load
collection_name2 = cf.gen_collection_name_by_testcase_name()
self.create_collection(client, collection_name2, schema=schema1,
index_params=index_params, consistency_level="Bounded")
# 6. insert data into collection2 with distinct ID range (start=10000)
nb2 = 1500
data2 = cf.gen_row_data_by_schema(nb=nb2, schema=schema1, start=10000)
self.insert(client, collection_name2, data2)
self.flush(client, collection_name2)
# 7. alter alias to collection2
self.alter_alias(client, collection_name2, alias_name)
# 8. verify alias now points to collection2 (count = nb2)
res2 = self.query(client, alias_name, filter=f"{ct.default_int64_field_name} >= 0",
output_fields=["count(*)"])
assert res2[0][0].get("count(*)") == nb2
# 9. verify search using alias returns collection2 IDs (>= 10000)
search_res, _ = self.search(client, alias_name, search_vectors, limit=default_limit,
output_fields=[ct.default_int64_field_name],
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": 1,
"pk_name": ct.default_int64_field_name,
"limit": default_limit,
"metric": "L2"})
for hit in search_res[0]:
assert hit[ct.default_int64_field_name] >= 10000, \
f"After alter, alias should point to collection2 (IDs >= 10000), got {hit[ct.default_int64_field_name]}"
# 10. verify collection1 data is unaffected
res1_after = self.query(client, collection_name1,
filter=f"{ct.default_int64_field_name} >= 0",
output_fields=["count(*)"])
assert res1_after[0][0].get("count(*)") == nb1
# cleanup
self.drop_alias(client, alias_name)
self.drop_collection(client, collection_name1)
self.drop_collection(client, collection_name2)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_create_drop_alias_operation_default(self):
"""
target: test collection creating and dropping alias
method:
1. create a collection with 10 partitions
2. create an alias for the collection
3. verify alias has same partitions as collection
4. drop the alias
5. verify alias is dropped and collection still exists
expected:
1. alias has same partitions as collection
2. alias can be dropped successfully
3. collection remains unchanged after alias operations
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Bounded")
# 2. create partitions
partition_names = []
for _ in range(10):
partition_name = cf.gen_unique_str("partition")
partition_names.append(partition_name)
self.create_partition(client, collection_name, partition_name)
# 3. create alias
alias_name = cf.gen_unique_str(prefix)
self.create_alias(client, collection_name, alias_name)
# 4. verify partitions in collection and alias
partitions = self.list_partitions(client, collection_name)
alias_partitions = self.list_partitions(client, alias_name)
assert partitions == alias_partitions
# 5. verify collection exists
assert self.has_collection(client, collection_name)[0]
assert self.has_collection(client, alias_name)[0]
# 6. drop alias
self.drop_alias(client, alias_name)
# 7. verify alias is dropped
error = {ct.err_code: 100,
ct.err_msg: f"can't find collection[database=default][collection={alias_name}]"}
self.describe_collection(client, alias_name,
check_task=CheckTasks.err_res,
check_items=error)
# 8. verify collection still exists and unchanged
assert self.has_collection(client, collection_name)[0]
collection_partitions = self.list_partitions(client, collection_name)
assert collection_partitions == partitions
# cleanup
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L2)
def test_milvus_client_collection_operations_by_alias(self):
"""
target: test collection operations using alias
method:
1. create collection with alias
2. verify has_collection works with alias
3. verify drop_collection fails with alias
expected:
1. has_collection returns True for alias
2. drop_collection fails with error message
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Bounded")
# 2. create alias
alias_name = cf.gen_unique_str(prefix)
self.create_alias(client, collection_name, alias_name)
# 3. verify has_collection works with alias
assert self.has_collection(client, alias_name)[0]
assert self.has_collection(client, collection_name)[0]
# 4. verify drop_collection fails with alias
error = {ct.err_code: 1,
ct.err_msg: f"cannot drop the collection via alias = {alias_name}"}
self.drop_collection(client, alias_name,
check_task=CheckTasks.err_res,
check_items=error)
# cleanup
self.drop_alias(client, alias_name)
self.drop_collection(client, collection_name)
assert not self.has_collection(client, collection_name)[0]
@pytest.mark.tags(CaseLabel.L2)
def test_milvus_client_rename_back_old_alias(self):
"""
target: test renaming collection to a previously dropped alias name
method:
1. create collection with alias
2. drop the alias
3. rename collection to the dropped alias name
expected:
1. rename collection successfully — dropped alias name is reusable
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
# 1. create collection
self.create_collection(client, collection_name, default_dim)
# 2. create alias
alias_name = cf.gen_unique_str(prefix)
self.create_alias(client, collection_name, alias_name)
# 3. drop the alias
self.drop_alias(client, alias_name)
assert self.list_aliases(client, collection_name)[0]["aliases"] == []
# 4. rename collection to the dropped alias name
self.rename_collection(client, collection_name, alias_name)
# cleanup
self.drop_collection(client, alias_name)
assert not self.has_collection(client, alias_name)[0]
@pytest.mark.tags(CaseLabel.L2)
def test_milvus_client_rename_back_old_collection(self):
"""
target: test renaming collection back to original name preserves alias binding
method:
1. create collection with alias
2. rename collection to a new name
3. rename back to old collection name
expected:
1. rename succeeds, alias still bound to the collection
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
# 1. create collection
self.create_collection(client, collection_name, default_dim)
# 2. create alias
alias_name = cf.gen_unique_str(prefix)
self.create_alias(client, collection_name, alias_name)
# 3. rename collection
new_collection_name = cf.gen_collection_name_by_testcase_name()
self.rename_collection(client, collection_name, new_collection_name)
# 4. rename back to old collection name
self.rename_collection(client, new_collection_name, collection_name)
assert self.has_collection(client, collection_name)[0]
assert not self.has_collection(client, new_collection_name)[0]
assert alias_name in self.list_aliases(client, collection_name)[0]["aliases"]
# cleanup
self.drop_alias(client, alias_name)
self.drop_collection(client, collection_name)
assert not self.has_collection(client, collection_name)[0]
class TestMilvusClientAliasOperationInvalid(TestMilvusClientV2Base):
""" Test cases of alias interface invalid operations"""
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_create_alias_for_non_exist_collection(self):
"""
target: test creating alias for a non-existent collection is rejected
method: create alias pointing to a collection name that does not exist
expected: raise exception with collection not found error
"""
client = self._client()
non_exist_collection = cf.gen_unique_str("non_exist_collection")
alias_name = cf.gen_unique_str(prefix)
error = {ct.err_code: 100,
ct.err_msg: f"collection not found[database=default][collection={non_exist_collection}]"}
self.create_alias(client, non_exist_collection, alias_name,
check_task=CheckTasks.err_res,
check_items=error)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_alter_alias_to_non_exist_collection(self):
"""
target: test altering alias to point to a non-existent collection is rejected
method: 1. create collection and bind alias
2. alter alias to point to a non-existent collection
expected: raise exception with collection not found error
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
self.create_collection(client, collection_name, default_dim, consistency_level="Bounded")
alias_name = cf.gen_unique_str(prefix)
self.create_alias(client, collection_name, alias_name)
non_exist_collection = cf.gen_unique_str("non_exist_collection")
error = {ct.err_code: 100,
ct.err_msg: f"collection not found[collection={non_exist_collection}]"}
self.alter_alias(client, non_exist_collection, alias_name,
check_task=CheckTasks.err_res,
check_items=error)
# cleanup
self.drop_alias(client, alias_name)
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_create_duplicate_alias(self):
"""
target: test create duplicate alias
method: create alias twice with same name to different collections
expected: raise exception
"""
client = self._client()
collection_name1 = cf.gen_collection_name_by_testcase_name()
collection_name2 = cf.gen_collection_name_by_testcase_name()
# 1. create collection1
self.create_collection(client, collection_name1, default_dim, consistency_level="Bounded")
# 2. create collection2
self.create_collection(client, collection_name2, default_dim, consistency_level="Bounded")
# 3. create alias for collection1
alias_name = cf.gen_unique_str(prefix)
self.create_alias(client, collection_name1, alias_name)
# 4. try to create same alias for collection2
error = {ct.err_code: 1,
ct.err_msg: f"{alias_name} is alias to another collection: {collection_name1}"}
self.create_alias(client, collection_name2, alias_name,
check_task=CheckTasks.err_res,
check_items=error)
# cleanup
self.drop_alias(client, alias_name)
self.drop_collection(client, collection_name1)
self.drop_collection(client, collection_name2)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_alter_not_exist_alias(self):
"""
target: test alter not exist alias
method: alter alias that not exists
expected: raise exception
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
alias_name = cf.gen_unique_str(prefix)
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Bounded")
# 2. create alias and link to the collection
self.create_alias(client, collection_name, alias_name)
# 3. alter alias, trying to link the collection to a non existing alias
non_exist_alias = cf.gen_unique_str(prefix)
error = {ct.err_code: 1600,
ct.err_msg: f"alias not found[database=default][alias={non_exist_alias}]"}
self.alter_alias(client, collection_name, non_exist_alias,
check_task=CheckTasks.err_res,
check_items=error)
# 4. cleanup
self.drop_alias(client, alias_name)
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L2)
def test_milvus_client_drop_not_exist_alias(self):
"""
target: test drop not exist alias
method: drop alias that not exists
expected: no exception
"""
client = self._client()
alias_name = cf.gen_unique_str(prefix)
# trying to drop a non existing alias
self.drop_alias(client, alias_name)
@pytest.mark.tags(CaseLabel.L2)
def test_milvus_client_drop_same_alias_twice(self):
"""
target: test drop same alias twice
method: drop alias twice
expected: no exception
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Bounded")
# 2. create alias
alias_name = cf.gen_unique_str(prefix)
self.create_alias(client, collection_name, alias_name)
# 3. drop alias first time
self.drop_alias(client, alias_name)
# 4. try to drop alias second time
self.drop_alias(client, alias_name)
# cleanup
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L0)
def test_milvus_client_reuse_alias_name(self):
"""
target: test reuse alias name from dropped collection
method:
1.create collection1 with alias
2.drop collection1
3.create collection2 with same alias name
expected: create collection2 successfully
"""
client = self._client()
collection_name1 = cf.gen_collection_name_by_testcase_name()
# 1. create collection1
self.create_collection(client, collection_name1, default_dim, consistency_level="Bounded")
# 2. create alias
alias_name = cf.gen_unique_str(prefix)
self.create_alias(client, collection_name1, alias_name)
# 3. drop the alias and collection1
self.drop_alias(client, alias_name)
self.drop_collection(client, collection_name1)
# 4. create collection2
collection_name2 = cf.gen_collection_name_by_testcase_name()
self.create_collection(client, collection_name2, default_dim, consistency_level="Bounded")
# 5. create alias with the previous alias name and assign it to collection2
self.create_alias(client, collection_name2, alias_name)
# 6. verify collection2
assert self.has_collection(client, collection_name2)[0]
assert self.has_collection(client, alias_name)[0]
# 7. verify alias is bound to collection2 via list_aliases
aliases_res = self.list_aliases(client, collection_name2)[0]
assert alias_name in aliases_res["aliases"]
# cleanup
self.drop_alias(client, alias_name)
self.drop_collection(client, collection_name2)
@pytest.mark.tags(CaseLabel.L0)
def test_milvus_client_rename_collection_to_alias_name(self):
"""
target: test rename collection to alias name
method:
1.create collection1 with alias
2.rename collection2 to alias name
expected: raise exception
"""
client = self._client()
collection_name1 = cf.gen_collection_name_by_testcase_name()
collection_name2 = cf.gen_collection_name_by_testcase_name()
# 1. create collection1
self.create_collection(client, collection_name1, default_dim, consistency_level="Bounded")
# 2. create alias
alias_name = cf.gen_unique_str(prefix)
self.create_alias(client, collection_name1, alias_name)
# 3. create collection2
self.create_collection(client, collection_name2, default_dim, consistency_level="Bounded")
# 4. try to rename collection2 to alias name
error = {ct.err_code: 1601,
ct.err_msg: f"alias and collection name conflict[database=default][alias={alias_name}]"}
self.rename_collection(client, collection_name2, alias_name,
check_task=CheckTasks.err_res,
check_items=error)
# cleanup
self.drop_alias(client, alias_name)
self.drop_collection(client, collection_name1)
self.drop_collection(client, collection_name2)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,427 @@
from typing import Any, Protocol, cast
import pytest
from base.client_v2_base import TestMilvusClientV2Base
from common.common_type import CaseLabel
from common.text_generator import generate_text_by_analyzer
class AnalyzerResult(Protocol):
"""Protocol for analyzer result to help with type inference"""
tokens: list[dict[str, Any]]
class TestMilvusClientAnalyzer(TestMilvusClientV2Base):
@staticmethod
def get_expected_jieba_tokens(text, analyzer_params):
"""
Generate expected tokens using rjieba based on analyzer parameters.
"""
import rjieba
tokenizer_config = analyzer_params.get("tokenizer", {})
if isinstance(tokenizer_config, str):
tokenizer_config = {}
# rjieba does not expose jieba-rs dynamic dictionary APIs. Fall back
# to targeted assertions in custom-dictionary cases.
if "dict" in tokenizer_config and tokenizer_config["dict"] != ["_default_"]:
return None
mode = tokenizer_config.get("mode", "search")
hmm = tokenizer_config.get("hmm", True)
if mode == "exact":
tokens = list(rjieba.cut(text, hmm))
elif mode == "search":
tokens = list(rjieba.cut_for_search(text, hmm))
else:
tokens = list(rjieba.cut(text, hmm))
# Filter out empty tokens
tokens = [token for token in tokens if token.strip()]
return tokens
analyzer_params_list = [
{
"tokenizer": "standard",
"filter": [
{
"type": "stop",
"stop_words": ["is", "the", "this", "a", "an", "and", "or"],
}
],
},
{
"tokenizer": "jieba",
"filter": [
{
"type": "stop",
"stop_words": ["is", "the", "this", "a", "an", "and", "or", "", "", "", "一个", "", ""],
}
],
},
{"tokenizer": "icu"},
# {
# "tokenizer": {"type": "lindera", "dict_kind": "ipadic"},
# "filter": [
# {
# "type": "stop",
# "stop_words": ["は", "が", "の", "に", "を", "で", "と", "た"],
# }
# ],
# },
# {"tokenizer": {"type": "lindera", "dict_kind": "ko-dic"}},
# {"tokenizer": {"type": "lindera", "dict_kind": "cc-cedict"}},
]
jieba_custom_analyzer_params_list = [
# # Test dict parameter with custom dictionary
{"tokenizer": {"type": "jieba", "dict": ["结巴分词器"], "mode": "exact", "hmm": False}},
# Test dict parameter with default dict and custom dict
{"tokenizer": {"type": "jieba", "dict": ["_default_", "结巴分词器"], "mode": "search", "hmm": False}},
# Test exact mode with hmm enabled
{"tokenizer": {"type": "jieba", "dict": ["结巴分词器"], "mode": "exact", "hmm": True}},
# Test search mode with hmm enabled
{"tokenizer": {"type": "jieba", "dict": ["结巴分词器"], "mode": "search", "hmm": True}},
# Test with only mode configuration
{"tokenizer": {"type": "jieba", "mode": "exact"}},
# Test with only hmm configuration
{"tokenizer": {"type": "jieba", "hmm": False}},
]
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("analyzer_params", analyzer_params_list)
def test_analyzer(self, analyzer_params):
"""
target: test analyzer
method: use different analyzer params, then run analyzer to get the tokens
expected: verify the tokens
"""
client = self._client()
text = generate_text_by_analyzer(analyzer_params)
res, _ = self.run_analyzer(client, text, analyzer_params, with_detail=True, with_hash=True)
res_2, _ = self.run_analyzer(client, text, analyzer_params, with_detail=True, with_hash=True)
# Cast to help type inference for gRPC response
analyzer_res = cast(AnalyzerResult, res)
analyzer_res_2 = cast(AnalyzerResult, res_2)
# verify the result are the same when run analyzer twice
for i in range(len(analyzer_res.tokens)):
assert analyzer_res.tokens[i]["token"] == analyzer_res_2.tokens[i]["token"]
assert analyzer_res.tokens[i]["hash"] == analyzer_res_2.tokens[i]["hash"]
assert analyzer_res.tokens[i]["start_offset"] == analyzer_res_2.tokens[i]["start_offset"]
assert analyzer_res.tokens[i]["end_offset"] == analyzer_res_2.tokens[i]["end_offset"]
assert analyzer_res.tokens[i]["position"] == analyzer_res_2.tokens[i]["position"]
assert analyzer_res.tokens[i]["position_length"] == analyzer_res_2.tokens[i]["position_length"]
tokens = analyzer_res.tokens
token_list = [r["token"] for r in tokens]
# Check tokens are not empty
assert len(token_list) > 0, "No tokens were generated"
# Check tokens are related to input text (all token should be a substring of the text)
assert all(token.lower() in text.lower() for token in token_list), (
"some of the tokens do not appear in the original text"
)
if "filter" in analyzer_params:
for filter in analyzer_params["filter"]:
if filter["type"] == "stop":
stop_words = filter["stop_words"]
assert not any(token in stop_words for token in tokens), "some of the tokens are stop words"
# Check hash value and detail
for r in tokens:
assert isinstance(r["hash"], int)
assert isinstance(r["start_offset"], int)
assert isinstance(r["end_offset"], int)
assert isinstance(r["position"], int)
assert isinstance(r["position_length"], int)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("analyzer_params", jieba_custom_analyzer_params_list)
def test_jieba_custom_analyzer(self, analyzer_params):
"""
target: test jieba analyzer with custom configurations
method: use different jieba analyzer params with dict, mode, and hmm configurations
expected: verify the tokens are generated correctly based on configuration
"""
client = self._client()
text = "milvus结巴分词器中文测试"
res, _ = self.run_analyzer(client, text, analyzer_params, with_detail=True)
analyzer_res = cast(AnalyzerResult, res)
tokens = analyzer_res.tokens
token_list = [r["token"] for r in tokens]
# Check tokens are not empty
assert len(token_list) > 0, "No tokens were generated"
# Generate expected tokens using rjieba and compare when the Python
# binding exposes the required tokenizer configuration.
expected_tokens = self.get_expected_jieba_tokens(text, analyzer_params)
if expected_tokens is None:
custom_words = [
word
for word in analyzer_params["tokenizer"].get("dict", [])
if word not in ("", "_default_", "_extend_default_")
]
assert all(word in token_list for word in custom_words), (
f"Expected custom words {custom_words}, but got {token_list}"
)
else:
assert sorted(token_list) == sorted(expected_tokens), f"Expected {expected_tokens}, but got {token_list}"
# Verify token details
for r in tokens:
assert isinstance(r["token"], str)
assert isinstance(r["start_offset"], int)
assert isinstance(r["end_offset"], int)
assert isinstance(r["position"], int)
assert isinstance(r["position_length"], int)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize(
"invalid_analyzer_params",
[
{"tokenizer": "invalid_tokenizer"},
{"tokenizer": 123},
{"tokenizer": None},
{"tokenizer": []},
{"tokenizer": {"type": "invalid_type"}},
{"tokenizer": {"type": None}},
{"filter": "invalid_filter"},
{"filter": [{"type": None}]},
{"filter": [{"invalid_key": "value"}]},
],
)
def test_analyzer_with_invalid_params(self, invalid_analyzer_params):
"""
target: test analyzer with invalid parameters
method: use invalid analyzer params and expect errors
expected: analyzer should raise appropriate exceptions
"""
client = self._client()
text = "test text for invalid analyzer"
with pytest.raises(Exception):
self.run_analyzer(client, text, invalid_analyzer_params)
@pytest.mark.tags(CaseLabel.L1)
def test_analyzer_with_empty_params(self):
"""
target: test analyzer with empty parameters (uses default)
method: use empty analyzer params
expected: analyzer should use default configuration and work normally
"""
client = self._client()
text = "test text for empty analyzer"
# Empty params should use default configuration
res, _ = self.run_analyzer(client, text, {})
analyzer_res = cast(AnalyzerResult, res)
assert len(analyzer_res.tokens) > 0
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize(
"invalid_text",
[
None,
123,
True,
False,
],
)
def test_analyzer_with_invalid_text(self, invalid_text):
"""
target: test analyzer with invalid text input
method: use valid analyzer params but invalid text
expected: analyzer should handle invalid text appropriately
"""
client = self._client()
analyzer_params = {"tokenizer": "standard"}
with pytest.raises(Exception):
self.run_analyzer(client, invalid_text, analyzer_params)
@pytest.mark.tags(CaseLabel.L1)
def test_analyzer_with_empty_text(self):
"""
target: test analyzer with empty text
method: use empty text input
expected: analyzer should return empty tokens
"""
client = self._client()
analyzer_params = {"tokenizer": "standard"}
res, _ = self.run_analyzer(client, "", analyzer_params)
analyzer_res = cast(AnalyzerResult, res)
assert len(analyzer_res.tokens) == 0
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize(
"text_input",
[
[],
{},
["list", "of", "strings"],
{"key": "value"},
],
)
def test_analyzer_with_structured_text(self, text_input):
"""
target: test analyzer with structured text input (list/dict)
method: use list or dict as text input
expected: analyzer should handle structured input and return tokens
"""
client = self._client()
analyzer_params = {"tokenizer": "standard"}
res, _ = self.run_analyzer(client, text_input, analyzer_params)
# For structured input, API returns direct list format
assert isinstance(res, list)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize(
"invalid_jieba_params",
[
{"tokenizer": {"type": "jieba", "dict": "not_a_list"}},
{"tokenizer": {"type": "jieba", "dict": [123, 456]}},
{"tokenizer": {"type": "jieba", "mode": "invalid_mode"}},
{"tokenizer": {"type": "jieba", "mode": 123}},
{"tokenizer": {"type": "jieba", "hmm": "not_boolean"}},
{"tokenizer": {"type": "jieba", "hmm": 123}},
],
)
def test_jieba_analyzer_with_invalid_config(self, invalid_jieba_params):
"""
target: test jieba analyzer with invalid configurations
method: use jieba analyzer with invalid dict, mode, or hmm values
expected: analyzer should raise appropriate exceptions
"""
client = self._client()
text = "测试文本 for jieba analyzer"
with pytest.raises(Exception):
self.run_analyzer(client, text, invalid_jieba_params)
@pytest.mark.tags(CaseLabel.L1)
def test_jieba_analyzer_with_empty_dict(self):
"""
target: test jieba analyzer with empty dictionary
method: use jieba analyzer with empty dict list
expected: analyzer should work with empty dict (uses default)
"""
client = self._client()
text = "测试文本 for jieba analyzer"
jieba_params = {"tokenizer": {"type": "jieba", "dict": []}}
res, _ = self.run_analyzer(client, text, jieba_params)
analyzer_res = cast(AnalyzerResult, res)
assert len(analyzer_res.tokens) > 0
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize(
"invalid_dict_config",
[
{"tokenizer": {"type": "jieba", "dict": None}},
{"tokenizer": {"type": "jieba", "dict": "invalid_string"}},
{"tokenizer": {"type": "jieba", "dict": 123}},
{"tokenizer": {"type": "jieba", "dict": True}},
{"tokenizer": {"type": "jieba", "dict": {"invalid": "dict"}}},
],
)
def test_jieba_analyzer_with_invalid_dict_values(self, invalid_dict_config):
"""
target: test jieba analyzer with invalid dict configurations
method: use jieba analyzer with invalid dict values
expected: analyzer should raise appropriate exceptions
"""
client = self._client()
text = "测试文本 for jieba analyzer"
with pytest.raises(Exception):
self.run_analyzer(client, text, invalid_dict_config)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize(
"edge_case_dict_config",
[
{"tokenizer": {"type": "jieba", "dict": ["", "valid_word"]}}, # Empty string in list
{"tokenizer": {"type": "jieba", "dict": ["valid_word", "valid_word"]}}, # Duplicate words
{"tokenizer": {"type": "jieba", "dict": ["_default_"]}}, # Only default dict
],
)
def test_jieba_analyzer_with_edge_case_dict_values(self, edge_case_dict_config):
"""
target: test jieba analyzer with edge case dict configurations
method: use jieba analyzer with edge case dict values
expected: analyzer should handle these cases gracefully
"""
client = self._client()
text = "测试文本 for jieba analyzer"
res, _ = self.run_analyzer(client, text, edge_case_dict_config, with_detail=True)
analyzer_res = cast(AnalyzerResult, res)
# These should work but might not be recommended usage
assert len(analyzer_res.tokens) >= 0
@pytest.mark.tags(CaseLabel.L1)
def test_jieba_analyzer_with_unknown_param(self):
"""
target: test jieba analyzer with unknown parameter
method: use jieba analyzer with invalid parameter name
expected: analyzer should ignore unknown parameters and work normally
"""
client = self._client()
text = "测试文本 for jieba analyzer"
jieba_params = {"tokenizer": {"type": "jieba", "invalid_param": "value"}}
res, _ = self.run_analyzer(client, text, jieba_params)
analyzer_res = cast(AnalyzerResult, res)
assert len(analyzer_res.tokens) > 0
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize(
"invalid_filter_params",
[
{"tokenizer": "standard", "filter": [{"type": "stop", "stop_words": "not_a_list"}]},
{"tokenizer": "standard", "filter": [{"type": "stop", "stop_words": [123, 456]}]},
{"tokenizer": "standard", "filter": [{"type": "invalid_filter_type"}]},
],
)
def test_analyzer_with_invalid_filter(self, invalid_filter_params):
"""
target: test analyzer with invalid filter configurations
method: use analyzer with invalid filter parameters
expected: analyzer should handle invalid filters appropriately
"""
client = self._client()
text = "This is a test text with stop words"
with pytest.raises(Exception):
self.run_analyzer(client, text, invalid_filter_params)
@pytest.mark.tags(CaseLabel.L1)
def test_analyzer_with_empty_stop_words(self):
"""
target: test analyzer with empty stop words list
method: use stop filter with empty stop_words list
expected: analyzer should work normally with empty stop words (no filtering)
"""
client = self._client()
text = "This is a test text with stop words"
filter_params = {"tokenizer": "standard", "filter": [{"type": "stop", "stop_words": []}]}
res, _ = self.run_analyzer(client, text, filter_params, with_detail=True)
analyzer_res = cast(AnalyzerResult, res)
tokens = analyzer_res.tokens
token_list = [r["token"] for r in tokens]
assert len(token_list) > 0
# With empty stop words, no filtering should occur
assert "is" in token_list # Common stop word should still be present
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,295 @@
# ruff: noqa: E712,E731,F401,F403,F405,F541,F841,I001,UP031,UP032,W291,W292,W293
# fmt: off
import pytest
import time
from base.client_v2_base import TestMilvusClientV2Base
from utils.util_log import test_log as log
from common import common_func as cf
from common import common_type as ct
from common.common_type import CaseLabel, CheckTasks
from utils.util_pymilvus import *
from common.constants import *
from pymilvus import DataType
from pymilvus import AnnSearchRequest
from pymilvus import WeightedRanker
prefix = "client_compact"
epsilon = ct.epsilon
default_nb = ct.default_nb
default_nb_medium = ct.default_nb_medium
default_nq = ct.default_nq
default_dim = ct.default_dim
default_limit = ct.default_limit
default_search_exp = "id >= 0"
exp_res = "exp_res"
default_search_string_exp = "varchar >= \"0\""
default_search_mix_exp = "int64 >= 0 && varchar >= \"0\""
default_invaild_string_exp = "varchar >= 0"
default_json_search_exp = "json_field[\"number\"] >= 0"
perfix_expr = 'varchar like "0%"'
default_search_field = ct.default_float_vec_field_name
default_search_params = ct.default_search_params
default_primary_key_field_name = "id"
default_vector_field_name = "vector"
default_float_field_name = ct.default_float_field_name
default_bool_field_name = ct.default_bool_field_name
default_string_field_name = ct.default_string_field_name
default_int32_array_field_name = ct.default_int32_array_field_name
default_string_array_field_name = ct.default_string_array_field_name
class TestMilvusClientCompactInvalid(TestMilvusClientV2Base):
""" Test case of compact interface """
"""
******************************************************************
# The following are invalid base cases
******************************************************************
"""
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.skip(reason="pymilvus issue 2588")
@pytest.mark.parametrize("name", [1, "12-s", "12 s", "(mn)", "中文", "%$#"])
def test_milvus_client_compact_invalid_collection_name_string(self, name):
"""
target: test compact with invalid collection name
method: create connection, collection, insert and hybrid search with invalid collection name
expected: Raise exception
"""
client = self._client()
error = {ct.err_code: 1100,
ct.err_msg: f"Invalid collection name: {name}. the first character of a collection name "
f"must be an underscore or letter: invalid parameter"}
self.compact(client, name,
check_task=CheckTasks.err_res, check_items=error)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.skip(reason="pymilvus issue 2587")
@pytest.mark.parametrize("name", [1])
def test_milvus_client_compact_invalid_collection_name_non_string(self, name):
"""
target: test compact with invalid collection name
method: create connection, collection, insert and hybrid search with invalid collection name
expected: Raise exception
"""
client = self._client()
error = {ct.err_code: 1100,
ct.err_msg: f"Invalid collection name: {name}. the first character of a collection name "
f"must be an underscore or letter: invalid parameter"}
self.compact(client, name,
check_task=CheckTasks.err_res, check_items=error)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("invalid_clustering", ["12-s", "12 s", "(mn)", "中文", "%$#"])
def test_milvus_client_compact_invalid_is_clustering(self, invalid_clustering):
"""
target: test compact with invalid collection name
method: create connection, collection, insert and hybrid search with invalid collection name
expected: Raise exception
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
# 1. create collection
self.create_collection(client, collection_name, default_dim)
error = {ct.err_code: 1,
ct.err_msg: f"is_clustering value {invalid_clustering} is illegal"}
self.compact(client, collection_name, is_clustering=invalid_clustering,
check_task=CheckTasks.err_res, check_items=error)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("invalid_job_id", ["12-s"])
def test_milvus_client_get_compact_state_invalid_job_id(self, invalid_job_id):
"""
target: test compact with invalid collection name
method: create connection, collection, insert and hybrid search with invalid collection name
expected: Raise exception
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
# 1. create collection
self.create_collection(client, collection_name, default_dim)
error = {ct.err_code: 1,
ct.err_msg: f"compaction_id value {invalid_job_id} is illegal"}
self.get_compaction_state(client, invalid_job_id,
check_task=CheckTasks.err_res, check_items=error)
_json_path_index_params = [
("INVERTED", "BOOL"),
("INVERTED", "DOUBLE"),
("INVERTED", "VARCHAR"),
("INVERTED", "JSON"),
("STL_SORT", "DOUBLE"),
("STL_SORT", "VARCHAR"),
("BITMAP", "BOOL"),
("BITMAP", "VARCHAR"),
]
class TestMilvusClientCompactValid(TestMilvusClientV2Base):
""" Test case of hybrid search interface """
@pytest.fixture(scope="function", params=[False, True])
def is_clustering(self, request):
yield request.param
@pytest.fixture(scope="function", params=_json_path_index_params, ids=[f"{t[0]}_{t[1]}" for t in _json_path_index_params])
def json_index_params(self, request):
yield request.param
@pytest.fixture(scope="function")
def supported_varchar_scalar_index(self, json_index_params):
yield json_index_params[0]
@pytest.fixture(scope="function")
def supported_json_cast_type(self, json_index_params):
yield json_index_params[1]
"""
******************************************************************
# The following are valid base cases
******************************************************************
"""
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("add_field", [True, False])
def test_milvus_client_compact_normal(self, is_clustering, add_field):
"""
target: test hybrid search with default normal case (2 vector fields)
method: create connection, collection, insert and hybrid search
expected: successfully
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
# 1. create collection
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(default_primary_key_field_name, DataType.INT64, is_primary=True, auto_id=False)
schema.add_field(default_vector_field_name, DataType.FLOAT_VECTOR, dim=dim)
schema.add_field(default_vector_field_name+"new", DataType.FLOAT_VECTOR, dim=dim)
schema.add_field(default_string_field_name, DataType.VARCHAR, max_length=64,
is_partition_key=True, is_clustering_key=is_clustering)
index_params = self.prepare_index_params(client)[0]
index_params.add_index(default_vector_field_name, metric_type="COSINE")
index_params.add_index(default_vector_field_name+"new", metric_type="L2")
self.create_collection(client, collection_name, dimension=dim, schema=schema, index_params=index_params)
# 2. insert
rng = np.random.default_rng(seed=19530)
rows = [
{default_primary_key_field_name: i, default_vector_field_name: list(rng.random((1, default_dim))[0]),
default_vector_field_name+"new": list(rng.random((1, default_dim))[0]),
default_string_field_name: str(i)} for i in range(10*default_nb)]
self.insert(client, collection_name, rows)
if add_field and not is_clustering:
self.add_collection_field(client, collection_name, field_name="field_new", data_type=DataType.INT64,
nullable=True, is_clustering_key=True)
rows_new = [
{default_primary_key_field_name: i, default_vector_field_name: list(rng.random((1, default_dim))[0]),
default_vector_field_name+"new": list(rng.random((1, default_dim))[0]),
default_string_field_name: str(i)} for i in range(10*default_nb, 11*default_nb)]
self.insert(client, collection_name, rows_new)
self.flush(client, collection_name)
# 3. compact
compact_id = self.compact(client, collection_name, is_clustering=is_clustering)[0]
cost = 180
start = time.time()
while True:
time.sleep(1)
res = self.get_compaction_state(client, compact_id, is_clustering=is_clustering)[0]
if res == "Completed":
break
if time.time() - start > cost:
raise Exception(1, f"Compact after index cost more than {cost}s")
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_compact_empty_collection(self, is_clustering):
"""
target: test compact to empty collection
method: create connection, collection, compact
expected: successfully
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
# 1. create collection
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(default_primary_key_field_name, DataType.INT64, is_primary=True, auto_id=False)
schema.add_field(default_vector_field_name, DataType.FLOAT_VECTOR, dim=dim)
schema.add_field(default_string_field_name, DataType.VARCHAR, max_length=64,
is_partition_key=True, is_clustering_key=is_clustering)
index_params = self.prepare_index_params(client)[0]
index_params.add_index(default_vector_field_name, metric_type="COSINE")
self.create_collection(client, collection_name, dimension=dim, schema=schema, index_params=index_params)
# 2. compact
self.compact(client, collection_name, is_clustering=is_clustering)
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_compact_json_path_index(self, is_clustering, supported_varchar_scalar_index,
supported_json_cast_type):
"""
target: test hybrid search with default normal case (2 vector fields)
method: create connection, collection, insert and hybrid search
expected: successfully
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
# 1. create collection
json_field_name = "my_json"
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(default_primary_key_field_name, DataType.INT64, is_primary=True, auto_id=False)
schema.add_field(default_vector_field_name, DataType.FLOAT_VECTOR, dim=dim)
schema.add_field(default_vector_field_name+"new", DataType.FLOAT_VECTOR, dim=dim)
schema.add_field(default_string_field_name, DataType.VARCHAR, max_length=64,
is_partition_key=True, is_clustering_key=is_clustering)
schema.add_field(json_field_name, DataType.JSON)
index_params = self.prepare_index_params(client)[0]
index_params.add_index(default_vector_field_name, metric_type="COSINE")
index_params.add_index(default_vector_field_name+"new", metric_type="L2")
index_params.add_index(field_name=json_field_name, index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type, "json_path": f"{json_field_name}['a']['b']"})
index_params.add_index(field_name=json_field_name,
index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a']"})
index_params.add_index(field_name=json_field_name,
index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}"})
index_params.add_index(field_name=json_field_name,
index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a'][0]['b']"})
index_params.add_index(field_name=json_field_name,
index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a'][0]"})
self.create_collection(client, collection_name, dimension=dim, schema=schema, index_params=index_params)
# 2. insert
rng = np.random.default_rng(seed=19530)
rows = [
{default_primary_key_field_name: i, default_vector_field_name: list(rng.random((1, default_dim))[0]),
default_vector_field_name+"new": list(rng.random((1, default_dim))[0]),
default_string_field_name: str(i),
json_field_name: {'a': {"b": i}}} for i in range(10*default_nb)]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
# 3. compact
compact_id = self.compact(client, collection_name, is_clustering=is_clustering)[0]
cost = 180
start = time.time()
while True:
time.sleep(1)
res = self.get_compaction_state(client, compact_id, is_clustering=is_clustering)[0]
if res == "Completed":
break
if time.time() - start > cost:
raise Exception(1, f"Compact after index cost more than {cost}s")
self.drop_collection(client, collection_name)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,246 @@
import pytest
from base.client_v2_base import TestMilvusClientV2Base
from utils.util_log import test_log as log
from common import common_func as cf
from common import common_type as ct
from common.common_type import CaseLabel, CheckTasks
from utils.util_pymilvus import *
import numpy as np
prefix = "milvus_client_api_query"
epsilon = ct.epsilon
default_nb = ct.default_nb
default_nb_medium = ct.default_nb_medium
default_nq = ct.default_nq
default_dim = ct.default_dim
default_limit = ct.default_limit
default_search_exp = "id >= 0"
exp_res = "exp_res"
default_search_string_exp = "varchar >= \"0\""
default_search_mix_exp = "int64 >= 0 && varchar >= \"0\""
default_invaild_string_exp = "varchar >= 0"
default_json_search_exp = "json_field[\"number\"] >= 0"
perfix_expr = 'varchar like "0%"'
default_search_field = ct.default_float_vec_field_name
default_search_params = ct.default_search_params
default_primary_key_field_name = "id"
default_vector_field_name = "vector"
default_float_field_name = ct.default_float_field_name
default_bool_field_name = ct.default_bool_field_name
default_string_field_name = ct.default_string_field_name
default_int32_array_field_name = ct.default_int32_array_field_name
default_string_array_field_name = ct.default_string_array_field_name
@pytest.mark.xdist_group("TestStaticFieldNoIndexAllExpr")
class TestStaticFieldNoIndexAllExpr(TestMilvusClientV2Base):
"""
Scalar fields are not indexed, and verify DQL requests
"""
def setup_class(self):
super().setup_class(self)
# init params
self.collection_name = cf.gen_collection_name_by_testcase_name(module_index=1)
self.enable_dynamic_field = False
self.ground_truth = {}
@pytest.fixture(scope="class", autouse=True)
def prepare_data(self, request):
"""
Initialize collection before test class runs
"""
# Get client connection
client = self._client()
# Create collection
# create schema
schema = self.create_schema(client, enable_dynamic_field=self.enable_dynamic_field)[0]
schema.add_field(ct.default_primary_key_field_name, DataType.INT64, is_primary=True, auto_id=False)
schema.add_field(ct.default_vector_field_name, DataType.FLOAT_VECTOR, dim=ct.default_dim)
schema.add_field(ct.default_bool_field_name, DataType.BOOL, nullable=True)
schema.add_field(ct.default_int8_field_name, DataType.INT8, nullable=True)
schema.add_field(ct.default_int16_field_name, DataType.INT16, nullable=True)
schema.add_field(ct.default_int32_field_name, DataType.INT32, nullable=True)
schema.add_field(ct.default_int64_field_name, DataType.INT64, nullable=True)
schema.add_field(ct.default_float_field_name, DataType.FLOAT, nullable=True)
schema.add_field(ct.default_double_field_name, DataType.DOUBLE, nullable=True)
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=100, nullable=True)
schema.add_field(ct.default_json_field_name, DataType.JSON, nullable=True)
schema.add_field(ct.default_int8_array_field_name, datatype=DataType.ARRAY, element_type=DataType.INT8,
max_capacity=5, nullable=True)
schema.add_field(ct.default_int16_array_field_name, datatype=DataType.ARRAY, element_type=DataType.INT16,
max_capacity=5, nullable=True)
schema.add_field(ct.default_int32_array_field_name, datatype=DataType.ARRAY, element_type=DataType.INT32,
max_capacity=5, nullable=True)
schema.add_field(ct.default_int64_array_field_name, datatype=DataType.ARRAY, element_type=DataType.INT64,
max_capacity=5, nullable=True)
schema.add_field(ct.default_bool_array_field_name, datatype=DataType.ARRAY, element_type=DataType.BOOL,
max_capacity=5, nullable=True)
schema.add_field(ct.default_float_array_field_name, datatype=DataType.ARRAY, element_type=DataType.FLOAT,
max_capacity=5, nullable=True)
schema.add_field(ct.default_double_array_field_name, datatype=DataType.ARRAY, element_type=DataType.DOUBLE,
max_capacity=5, nullable=True)
schema.add_field(ct.default_string_array_field_name, datatype=DataType.ARRAY, element_type=DataType.VARCHAR,
max_capacity=5, max_length=100, nullable=True)
# prepare index params
index_params = self.prepare_index_params(client)[0]
index_params.add_index(field_name=default_vector_field_name, index_type="AUTOINDEX", metric_type="COSINE")
# create collection with the above schema and index params
self.create_collection(client, self.collection_name, schema=schema,
index_params=index_params, force_teardown=False)
# Generate vectors and all scalar data
vectors = cf.gen_vectors(default_nb + 60, default_dim)
inserted_data_distribution = ct.get_all_kind_data_distribution
nb_single = 50
rows_list = []
for i in range(len(inserted_data_distribution)):
rows = [{ct.default_primary_key_field_name: j, ct.default_vector_field_name: vectors[j],
ct.default_bool_field_name: bool(j) if (i % 2 == 0) else None,
ct.default_int8_field_name: np.int8(j) if (i % 2 == 0) else None,
ct.default_int16_field_name: np.int16(j) if (i % 2 == 0) else None,
ct.default_int32_field_name: np.int32(j) if (i % 2 == 0) else None,
ct.default_int64_field_name: j if (i % 2 == 0) else None,
ct.default_float_field_name: j * 1.0 if (i % 2 == 0) else None,
ct.default_double_field_name: j * 1.0 if (i % 2 == 0) else None,
ct.default_string_field_name: f'{j}' if (i % 2 == 0) else None,
ct.default_json_field_name: inserted_data_distribution[i],
ct.default_int8_array_field_name: [np.int8(j), np.int8(j)] if (i % 2 == 0) else None,
ct.default_int16_array_field_name: [j, j + 1] if (i % 2 == 0) else None,
ct.default_int32_array_field_name: [j, j + 1] if (i % 2 == 0) else None,
ct.default_int64_array_field_name: [j, j + 1] if (i % 2 == 0) else None,
ct.default_bool_array_field_name: [bool(j), bool(j + 1)] if (i % 2 == 0) else None,
ct.default_float_array_field_name: [j * 1.0, (j + 1) * 1.0] if (i % 2 == 0) else None,
ct.default_double_array_field_name: [j * 1.0, (j + 1) * 1.0] if (i % 2 == 0) else None,
ct.default_string_array_field_name: [f'{j}', f'{j + 1}'] if (i % 2 == 0) else None
} for j in range(i * nb_single, (i + 1) * nb_single)]
assert len(rows) == nb_single
# insert
self.insert(client, collection_name=self.collection_name, data=rows)
log.info(f"inserted {nb_single} {inserted_data_distribution[i]}")
rows_list.extend(rows)
assert len(rows_list) == nb_single * len(inserted_data_distribution)
# calculated the ground truth for all fields with its supported expressions
expr_fields = ct.all_expr_fields
compare_dict = {}
one_dict = {}
for field in expr_fields:
globals()[field] = rows_list[0][field]
for field in expr_fields:
express_list = cf.gen_field_expressions_all_single_operator_each_field(field)
for i in range(len(express_list)):
expression = express_list[i].replace("&&", "and").replace("||", "or")
compare_dict.setdefault(field, {})
one_dict.setdefault(f'{field}', [])
compare_dict[field].setdefault(f'{i}', one_dict)
compare_dict[field][f'{i}'].setdefault("id_list", [])
for j in range(nb_single*len(inserted_data_distribution)):
globals()[field] = rows_list[j][field]
log.info("binbin_debug1")
log.info(field)
if (int8 is None) or (int16 is None) or (int32 is None) or (int64 is None)\
or (float is None) or (double is None) or (varchar is None) or (bool_field is None)\
or (int8_array is None) or (int16_array is None) or (int32_array is None) or (int64_array is None)\
or (bool_array is None) or (float_array is None) or (double_array is None) or (string_array is None):
if "is null" or "IS NULL" in expression:
compare_dict[field][f'{i}'][field].append(rows_list[j][field])
compare_dict[field][f'{i}']["id_list"].append(
rows_list[j][ct.default_primary_key_field_name])
continue
else:
if ("is not null" in expression) or ("IS NOT NULL" in expression):
compare_dict[field][f'{i}'][field].append(rows_list[j][field])
compare_dict[field][f'{i}']["id_list"].append(
rows_list[j][ct.default_primary_key_field_name])
continue
if ("is null" in expression) or ("IS NULL" in expression):
continue
log.info("binbin_debug")
log.info(expression)
if not expression or eval(expression):
compare_dict[field][f'{i}'][field].append(rows_list[j][field])
compare_dict[field][f'{i}']["id_list"].append(rows_list[j][ct.default_primary_key_field_name])
log.info("binbin_debug_2")
# log.info(compare_dict)
self.ground_truth = compare_dict
# flush collection, segment sealed
self.flush(client, self.collection_name)
# load collection
self.load_collection(client, self.collection_name)
def teardown():
self.drop_collection(self._client(), self.collection_name)
request.addfinalizer(teardown)
def check_query_res(self, res, expr_field: str) -> list:
""" Ensure that primary key field values are unique """
real_data = {x[0]: x[1] for x in zip(self.insert_data.get(self.primary_field),
self.insert_data.get(expr_field))}
if len(real_data) != len(self.insert_data.get(self.primary_field)):
log.warning("[TestNoIndexDQLExpr] The primary key values are not unique, " +
"only check whether the res value is within the inserted data")
return [(r.get(self.primary_field), r.get(expr_field)) for r in res if
r.get(expr_field) not in self.insert_data.get(expr_field)]
return [(r[self.primary_field], r[expr_field], real_data[r[self.primary_field]]) for r in res if
r[expr_field] != real_data[r[self.primary_field]]]
@pytest.mark.tags(CaseLabel.L3)
@pytest.mark.parametrize("expr_field", ct.all_expr_fields)
def test_milvus_client_query_all_field_type_all_data_distribution_all_expressions_array_all(self, expr_field):
"""
target: test query using expression fields with all supported field type after all supported scalar index
with all supported basic expressions
method: Query using expression on all supported fields after all scalar indexes with all supported basic expressions
step: 1. create collection
2. insert with different data distribution
3. flush if specified
4. query when there is no index applying on each field under all supported expressions
5. release if specified
6. prepare index params with all supported scalar index on all scalar fields
7. create index
8. create same index twice
9. reload collection if released before to make sure the new index load successfully
10. sleep for 60s to make sure the new index load successfully without release and reload operations
11. query after there is index applying on each supported field under all supported expressions
which should get the same result with that without index
expected: query successfully after there is index applying on each supported field under all expressions which
should get the same result with that without index
"""
client = self._client()
express_list = cf.gen_field_expressions_all_single_operator_each_field(expr_field)
compare_dict = self.ground_truth[expr_field]
for i in range(len(express_list)):
expression = express_list[i]
json_list = []
id_list = []
log.info(f"query with filter '{expression}' without scalar index is:")
count = self.query(client, collection_name=self.collection_name, filter=expression,
output_fields=["count(*)"])[0]
log.info(f"The count(*) after query with filter '{expression}' without scalar index is: {count}")
assert count == len(compare_dict[f'{i}']["id_list"])
res = self.query(client, collection_name=self.collection_name, filter=expression,
output_fields=[expr_field])[0]
for single in res:
id_list.append(single[f"{default_primary_key_field_name}"])
json_list.append(single[expr_field])
if len(json_list) != len(compare_dict[f'{i}'][expr_field]):
log.debug(f"the field {expr_field} value without scalar index under expression '{expression}' is:")
log.debug(json_list)
log.debug(f"the field {expr_field} value without scalar index to be compared under expression '{expression}' is:")
log.debug(compare_dict[f'{i}'][expr_field])
assert json_list == compare_dict[f'{i}'][expr_field]
if len(id_list) != len(compare_dict[f'{i}']["id_list"]):
log.debug(f"primary key field {default_primary_key_field_name} without scalar index under expression '{expression}' is:")
log.debug(id_list)
log.debug(f"primary key field {default_primary_key_field_name} without scalar index to be compared under expression '{expression}' is:")
log.debug(compare_dict[f'{i}']["id_list"])
assert id_list == compare_dict[f'{i}']["id_list"]
log.info(f"PASS with expression {expression}")
@@ -0,0 +1,583 @@
import pytest
from base.client_v2_base import TestMilvusClientV2Base
from utils.util_log import test_log as log
from common import common_func as cf
from common import common_type as ct
from common.common_type import CaseLabel, CheckTasks
from utils.util_pymilvus import *
from common.constants import *
from pymilvus import DataType
prefix = "client_search"
partition_prefix = "client_partition"
db_prefix = "client_database"
epsilon = ct.epsilon
default_nb = ct.default_nb
default_nb_medium = ct.default_nb_medium
default_nq = ct.default_nq
default_dim = ct.default_dim
default_limit = ct.default_limit
default_search_exp = "id >= 0"
exp_res = "exp_res"
default_search_string_exp = "varchar >= \"0\""
default_search_mix_exp = "int64 >= 0 && varchar >= \"0\""
default_invaild_string_exp = "varchar >= 0"
default_json_search_exp = "json_field[\"number\"] >= 0"
perfix_expr = 'varchar like "0%"'
default_search_field = ct.default_float_vec_field_name
default_search_params = ct.default_search_params
default_primary_key_field_name = "id"
default_vector_field_name = "vector"
default_float_field_name = ct.default_float_field_name
default_bool_field_name = ct.default_bool_field_name
default_string_field_name = ct.default_string_field_name
default_int32_array_field_name = ct.default_int32_array_field_name
default_string_array_field_name = ct.default_string_array_field_name
class TestMilvusClientDatabaseInvalid(TestMilvusClientV2Base):
""" Test case of database """
"""
******************************************************************
# The following are invalid base cases
******************************************************************
"""
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("db_name", ["12-s", "12 s", "(mn)", "中文", "%$#", " "])
def test_milvus_client_create_database_invalid_db_name(self, db_name):
"""
target: test fast create database with invalid db name
method: create database with invalid db name
expected: raise exception
"""
client = self._client()
# 1. create database
error = {ct.err_code: 802, ct.err_msg: f"the first character of a database name must be an underscore or letter: "
f"invalid database name[database={db_name}]"}
self.create_database(client, db_name,
check_task=CheckTasks.err_res, check_items=error)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_create_database_name_over_max_length(self):
"""
target: test fast create database with over max db name length
method: create database with over max db name length
expected: raise exception
"""
client = self._client()
# 1. create database
db_name = "a".join("a" for i in range(256))
error = {ct.err_code: 802, ct.err_msg: f"the length of a database name must be less than 255 characters"}
self.create_database(client, db_name,
check_task=CheckTasks.err_res, check_items=error)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_create_database_name_with_default(self):
"""
target: test fast create db name with default
method: create db name with default
expected: raise exception
"""
client = self._client()
# 1. create database
db_name = "default"
error = {ct.err_code: 65535, ct.err_msg: f"database already exist: {db_name}"}
self.create_database(client, db_name, default_dim,
check_task=CheckTasks.err_res, check_items=error)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_create_database_with_existed_name(self):
"""
target: test fast create db name with existed name
method: create db name with existed name
expected: raise exception
"""
client = self._client()
# 1. create database
db_name = cf.gen_unique_str(db_prefix)
self.create_database(client, db_name)
dbs = self.list_databases(client)[0]
assert db_name in dbs
error = {ct.err_code: 65535, ct.err_msg: f"database already exist: {db_name}"}
self.create_database(client, db_name, default_dim,
check_task=CheckTasks.err_res, check_items=error)
self.drop_database(client, db_name)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.skip(reason="pymilvus issue 2683")
@pytest.mark.parametrize("properties", ["hhh", []])
def test_milvus_client_create_database_with_invalid_properties(self, properties):
"""
target: test fast create db name with invalid properties
method: create db name with invalid properties
expected: raise exception
actual: Currently such errors are not very readable,
and entries of numeric types such as 1.11, 111 are not blocked
"""
client = self._client()
# 1. create database
db_name = cf.gen_unique_str(db_prefix)
error = {ct.err_code: 1, ct.err_msg: f"Unexpected error, message=<unsupported operand type(s) for +: 'float' and '{type(properties).__name__}'>"}
self.create_database(client, db_name, properties,
check_task=CheckTasks.err_res, check_items=error)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("properties", [{"database.rep.number": 3}])
@pytest.mark.skip("A param that does not currently exist will simply have no effect, "
"but it would be better if an error were reported.")
def test_milvus_client_create_database_with_nonexistent_property_params(self, properties):
"""
target: test fast create db name with nonexistent property params
method: create db name with nonexistent property params
expected: raise exception
"""
client = self._client()
# 1. create database
db_name = cf.gen_unique_str(db_prefix)
error = {ct.err_code: 1, ct.err_msg: f""}
self.create_database(client, db_name, properties=properties,
check_task=CheckTasks.err_res, check_items=error)
self.drop_database(client, db_name)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("db_name", ["12-s", "12 s", "(mn)", "中文", "%$#", " "])
def test_milvus_client_drop_database_invalid_db_name(self, db_name):
"""
target: test drop database with invalid db name
method: drop database with invalid db name
expected: raise exception
"""
client = self._client()
error = {ct.err_code: 802, ct.err_msg: f"the first character of a database name must be an underscore or letter: "
f"invalid database name[database={db_name}]"}
self.drop_database(client, db_name,
check_task=CheckTasks.err_res, check_items=error)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("db_name", ["nonexistent"])
@pytest.mark.skip("Deleting a db that does not exist does not report an error, "
"but it would be better if an error were reported.")
def test_milvus_client_drop_database_nonexistent_db_name(self, db_name):
"""
target: test drop database with nonexistent db name
method: drop database with nonexistent db name
expected: raise exception
"""
client = self._client()
error = {ct.err_code: 802, ct.err_msg: f""}
self.drop_database(client, db_name,
check_task=CheckTasks.err_res, check_items=error)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_drop_database_has_collections(self):
"""
target: test drop database which has collections
method: drop database which has collections
expected: raise exception
"""
client = self._client()
# 1. create database
db_name = cf.gen_unique_str(db_prefix)
self.create_database(client, db_name)
dbs = self.list_databases(client)[0]
assert db_name in dbs
# 2. create collection
self.use_database(client, db_name)
collection_name = cf.gen_unique_str(prefix)
self.create_collection(client, collection_name, default_dim)
collections = self.list_collections(client)[0]
assert collection_name in collections
# 3. drop database
error = {ct.err_code: 65535, ct.err_msg: f"{db_name} not empty, must drop all collections before drop database"}
self.drop_database(client, db_name,
check_task=CheckTasks.err_res, check_items=error)
self.drop_collection(client, collection_name)
self.drop_database(client, db_name)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("db_name", ["default"])
def test_milvus_client_list_databases_with_params(self, db_name):
"""
target: test list database with params
method: list database with params
expected: raise exception
"""
client = self._client()
res, _ = self.list_databases(client, db_name=db_name)
assert "default" in res
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("db_name", ["12-s", "12 s", "(mn)", "中文", "%$#", " ", "nonexistent"])
def test_milvus_client_describe_database_invalid_db_name(self, db_name):
"""
target: test describe database with invalid db name
method: describe database with invalid db name
expected: raise exception
"""
client = self._client()
# 1. create database
error = {ct.err_code: 800, ct.err_msg: f"database not found[database={db_name}]"}
self.describe_database(client, db_name,
check_task=CheckTasks.err_res, check_items=error)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("db_name", ["%$#", "test", " "])
def test_milvus_client_alter_database_properties_nonexistent_db_name(self, db_name):
"""
target: test alter database properties with nonexistent db name
method: alter database properties with nonexistent db name
expected: raise exception
"""
client = self._client()
# alter database properties
properties = {"database.replica.number": 2}
error = {ct.err_code: 800, ct.err_msg: f"database not found[database={db_name}]"}
self.alter_database_properties(client, db_name, properties,
check_task=CheckTasks.err_res,
check_items=error)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("properties", ["tt"])
def test_milvus_client_alter_database_properties_invalid_format(self, properties):
"""
target: test alter database properties with invalid properties format
method: alter database properties with invalid properties format
expected: raise exception
"""
client = self._client()
# 1. create database
db_name = cf.gen_unique_str(db_prefix)
self.create_database(client, db_name)
dbs = self.list_databases(client)[0]
assert db_name in dbs
error = {ct.err_code: 1, ct.err_msg: f"'str' object has no attribute 'items'"}
self.alter_database_properties(client, db_name, properties,
check_task=CheckTasks.err_res,
check_items=error)
self.drop_database(client, db_name)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_alter_database_properties_invalid_params(self):
"""
target: test describe database with invalid db name
method: describe database with invalid db name
expected: raise exception
actual: run successfully
"""
client = self._client()
# 1. create database
db_name = cf.gen_unique_str(db_prefix)
properties = {"database.force.deny.writing": "true",
"database.replica.number": "3"}
self.create_database(client, db_name, properties=properties)
dbs = self.list_databases(client)[0]
assert db_name in dbs
self.describe_database(client, db_name,
check_task=CheckTasks.check_describe_database_property,
check_items={"db_name": db_name,
"database.force.deny.writing": "true",
"database.replica.number": "3"})
alter_properties = {"data.replica.number": 2}
self.alter_database_properties(client, db_name, properties=alter_properties)
describe = self.describe_database(client, db_name)[0]
self.drop_database(client, db_name)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("db_name", ["%$#", "test", " "])
def test_milvus_client_drop_database_properties_nonexistent_db_name(self, db_name):
"""
target: test drop database properties with nonexistent db name
method: drop database properties with nonexistent db name
expected: raise exception
"""
client = self._client()
# alter database properties
properties = {"data.replica.number": 2}
error = {ct.err_code: 800, ct.err_msg: f"database not found[database={db_name}]"}
self.drop_database_properties(client, db_name, properties,
check_task=CheckTasks.err_res,
check_items=error)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("properties", ["", {}, []])
def test_milvus_client_drop_database_properties_invalid_format(self, properties):
"""
target: test drop database properties with invalid properties format
method: drop database properties with invalid properties format
expected: raise exception
"""
client = self._client()
# 1. create database
db_name = cf.gen_unique_str(db_prefix)
self.create_database(client, db_name)
dbs = self.list_databases(client)[0]
assert db_name in dbs
error = {ct.err_code: 65535, ct.err_msg: f"alter database with empty properties and delete keys, "
f"expected to set either properties or delete keys"}
self.drop_database_properties(client, db_name, property_keys=properties,
check_task=CheckTasks.err_res,
check_items=error)
self.drop_database(client, db_name)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_drop_database_properties_invalid_params(self):
"""
target: test drop database properties with invalid properties
method: drop database properties with invalid properties
expected: raise exception
actual: case success, nothing changed
"""
client = self._client()
# 1. create database
db_name = cf.gen_unique_str(db_prefix)
properties = {"database.force.deny.writing": "true",
"database.replica.number": "3"}
self.create_database(client, db_name, properties=properties)
dbs = self.list_databases(client)[0]
assert db_name in dbs
self.describe_database(client, db_name,
check_task=CheckTasks.check_describe_database_property,
check_items={"db_name": db_name,
"database.force.deny.writing": "true",
"database.replica.number": "3"})
drop_properties = {"data.replica.number": 2}
self.drop_database_properties(client, db_name, property_keys=drop_properties)
describe = self.describe_database(client, db_name)[0]
self.drop_database(client, db_name)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("db_name", ["nonexistent"])
def test_milvus_client_use_database_nonexistent_db_name(self, db_name):
"""
target: test use database with nonexistent db name
method: use database with nonexistent db name
expected: raise exception
"""
client = self._client()
error = {ct.err_code: 800, ct.err_msg: f"database not found[database={db_name}]"}
self.use_database(client, db_name,
check_task=CheckTasks.err_res, check_items=error)
self.using_database(client, db_name,
check_task=CheckTasks.err_res, check_items=error)
class TestMilvusClientDatabaseValid(TestMilvusClientV2Base):
""" Test case of database interface """
"""
******************************************************************
# The following are valid base cases
******************************************************************
"""
@pytest.mark.tags(CaseLabel.L0)
def test_milvus_client_create_drop_database_default(self):
"""
target: test create and drop database normal case
method: 1. create database 2. create collection 3. insert data 4. search & query 5. drop collection & database
expected: run successfully
"""
client = self._client()
# 1. create database
db_name = cf.gen_unique_str(db_prefix)
self.create_database(client, db_name)
dbs = self.list_databases(client)[0]
assert db_name in dbs
self.using_database(client, db_name)
# 2. create collection
collection_name = cf.gen_unique_str(prefix)
dim = 128
self.create_collection(client, collection_name, dim)
collections = self.list_collections(client)[0]
assert collection_name in collections
self.describe_collection(client, collection_name,
check_task=CheckTasks.check_describe_collection_property,
check_items={"collection_name": collection_name,
"dim": dim,
"consistency_level": 0})
# 3. insert
rng = np.random.default_rng(seed=19530)
rows = [{default_primary_key_field_name: i, default_vector_field_name: list(rng.random((1, default_dim))[0]),
default_float_field_name: i * 1.0, default_string_field_name: str(i)} for i in range(default_nb)]
self.insert(client, collection_name, rows)
# 4. search
vectors_to_search = rng.random((1, default_dim))
self.search(client, collection_name, vectors_to_search,
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": len(vectors_to_search),
"pk_name": default_primary_key_field_name,
"limit": default_limit})
# 5. query
self.query(client, collection_name, filter=default_search_exp,
check_task=CheckTasks.check_query_results,
check_items={exp_res: rows,
"with_vec": True,
"pk_name": default_primary_key_field_name})
# 6. drop action
self.drop_collection(client, collection_name)
self.drop_database(client, db_name)
@pytest.mark.tags(CaseLabel.L0)
def test_milvus_client_create_database_with_properties(self):
"""
target: test create database with properties
method: 1. create database 2. create collection 3. insert data 4. search & query 5. drop collection & database
expected: run successfully
"""
client = self._client()
# 1. create database
db_name = cf.gen_unique_str(db_prefix)
properties = {"database.force.deny.writing": "false",
"database.replica.number": "1"}
self.create_database(client, db_name, properties=properties)
describe = self.describe_database(client, db_name)
dbs = self.list_databases(client)[0]
assert db_name in dbs
self.describe_database(client, db_name,
check_task=CheckTasks.check_describe_database_property,
check_items={"db_name": db_name,
"database.force.deny.writing": "false",
"database.replica.number": "1"})
self.using_database(client, db_name)
# 2. create collection
collection_name = cf.gen_unique_str(prefix)
dim = 128
self.create_collection(client, collection_name, dim)
collections = self.list_collections(client)[0]
assert collection_name in collections
self.describe_collection(client, collection_name,
check_task=CheckTasks.check_describe_collection_property,
check_items={"collection_name": collection_name,
"dim": dim,
"consistency_level": 0})
# 3. insert
rng = np.random.default_rng(seed=19530)
rows = [{default_primary_key_field_name: i, default_vector_field_name: list(rng.random((1, default_dim))[0]),
default_float_field_name: i * 1.0, default_string_field_name: str(i)} for i in range(default_nb)]
self.insert(client, collection_name, rows)
# 4. search
vectors_to_search = rng.random((1, default_dim))
self.search(client, collection_name, vectors_to_search,
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": len(vectors_to_search),
"pk_name": default_primary_key_field_name,
"limit": default_limit})
# 5. query
self.query(client, collection_name, filter=default_search_exp,
check_task=CheckTasks.check_query_results,
check_items={exp_res: rows,
"with_vec": True,
"pk_name": default_primary_key_field_name})
# 6. drop action
self.drop_collection(client, collection_name)
self.drop_database(client, db_name)
@pytest.mark.tags(CaseLabel.L0)
def test_milvus_client_alter_database_properties_default(self):
"""
target: test alter database with properties
method: 1. create database 2. alter database properties
expected: run successfully
"""
client = self._client()
# 1. create database
db_name = cf.gen_unique_str(db_prefix)
properties = {"database.force.deny.writing": "true",
"database.replica.number": "3"}
self.create_database(client, db_name, properties=properties)
dbs = self.list_databases(client)[0]
assert db_name in dbs
self.describe_database(client, db_name,
check_task=CheckTasks.check_describe_database_property,
check_items={"db_name": db_name,
"database.force.deny.writing": "true",
"database.replica.number": "3"})
self.using_database(client, db_name)
alter_properties = {"database.replica.number": "2",
"database.force.deny.reading": "true"}
self.alter_database_properties(client, db_name, properties=alter_properties)
self.describe_database(client, db_name,
check_task=CheckTasks.check_describe_database_property,
check_items={"db_name": db_name,
"database.force.deny.writing": "true",
"database.force.deny.reading": "true",
"database.replica.number": "2"})
# 6. drop action
self.drop_database(client, db_name)
@pytest.mark.tags(CaseLabel.L0)
def test_milvus_client_drop_database_properties_default(self):
"""
target: test drop database with properties
method: 1. create database 2. drop database properties
expected: run successfully
"""
client = self._client()
# 1. create database
db_name = cf.gen_unique_str(db_prefix)
properties = {"database.force.deny.writing": "true",
"database.force.deny.reading": "true",
"database.replica.number": "3",
"database.max.collections": 100,
"database.diskQuota.mb": 10240}
self.create_database(client, db_name, properties=properties)
dbs = self.list_databases(client)[0]
assert db_name in dbs
self.describe_database(client, db_name,
check_task=CheckTasks.check_describe_database_property,
check_items=properties)
self.using_database(client, db_name)
drop1 = {"database.replica.number"}
self.drop_database_properties(client, db_name, property_keys=drop1)
describe = self.describe_database(client, db_name)[0]
self.describe_database(client, db_name,
check_task=CheckTasks.check_describe_database_property,
check_items={"database.replica.number": "Missing"})
drop2 = ["database.force.deny.writing", "database.force.deny.reading"]
self.drop_database_properties(client, db_name, property_keys=drop2)
describe = self.describe_database(client, db_name)[0]
self.describe_database(client, db_name,
check_task=CheckTasks.check_describe_database_property,
check_items={"database.force.deny.writing": "Missing",
"database.force.deny.reading": "Missing",
"properties_length": 3})
# drop3 = "database.max.collections"
# self.drop_database_properties(client, db_name, property_keys=drop3)
# it doesn't work, but no error reported
# 6. drop action
self.drop_database(client, db_name)
@pytest.mark.tags(CaseLabel.L0)
def test_milvus_client_use_database_default(self):
"""
target: test use_database
method: 1. create another database 2. create collection in defalut db & another db 3. list collections
expected: run successfully
"""
client = self._client()
# 1. create database
db_name = cf.gen_unique_str(db_prefix)
self.create_database(client, db_name)
dbs = self.list_databases(client)[0]
assert db_name in dbs
collection_name_default_db = cf.gen_unique_str(prefix)
self.create_collection(client, collection_name_default_db, default_dim)
collections_default_db = self.list_collections(client)[0]
assert collection_name_default_db in collections_default_db
self.use_database(client, db_name)
collection_name = cf.gen_unique_str(prefix)
self.create_collection(client, collection_name, default_dim)
collections = self.list_collections(client)[0]
assert collection_name in collections
assert collections_default_db not in collections
# 6. drop action
self.drop_collection(client, collection_name)
self.drop_database(client, db_name)
self.use_database(client, "default")
self.drop_collection(client, collection_name_default_db)
@@ -0,0 +1,442 @@
# ruff: noqa: E712,E731,F401,F403,F405,F541,F841,I001,UP031,UP032,W291,W292,W293
# fmt: off
import pytest
from base.client_v2_base import TestMilvusClientV2Base
from utils.util_log import test_log as log
from common import common_func as cf
from common import common_type as ct
from common.common_type import CaseLabel, CheckTasks
from utils.util_pymilvus import *
prefix = "client_delete"
epsilon = ct.epsilon
default_nb = ct.default_nb
default_nb_medium = ct.default_nb_medium
default_nq = ct.default_nq
default_dim = ct.default_dim
default_limit = ct.default_limit
default_search_exp = "id >= 0"
exp_res = "exp_res"
default_search_string_exp = "varchar >= \"0\""
default_search_mix_exp = "int64 >= 0 && varchar >= \"0\""
default_invaild_string_exp = "varchar >= 0"
default_json_search_exp = "json_field[\"number\"] >= 0"
perfix_expr = 'varchar like "0%"'
default_search_field = ct.default_float_vec_field_name
default_search_params = ct.default_search_params
default_primary_key_field_name = "id"
default_vector_field_name = "vector"
default_float_field_name = ct.default_float_field_name
default_bool_field_name = ct.default_bool_field_name
default_string_field_name = ct.default_string_field_name
default_int32_array_field_name = ct.default_int32_array_field_name
default_string_array_field_name = ct.default_string_array_field_name
class TestMilvusClientDeleteInvalid(TestMilvusClientV2Base):
""" Test case of search interface """
@pytest.fixture(scope="function", params=[False, True])
def auto_id(self, request):
yield request.param
@pytest.fixture(scope="function", params=["COSINE", "L2"])
def metric_type(self, request):
yield request.param
"""
******************************************************************
# The following are invalid base cases
******************************************************************
"""
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_delete_with_filters_and_ids(self):
"""
target: test delete (high level api) with ids and filters
method: create connection, collection, insert, delete, and search
expected: raise exception
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Strong")
# 2. insert
default_nb = 1000
rng = np.random.default_rng(seed=19530)
rows = [{default_primary_key_field_name: i, default_vector_field_name: list(rng.random((1, default_dim))[0]),
default_float_field_name: i * 1.0, default_string_field_name: str(i)} for i in range(default_nb)]
pks = self.insert(client, collection_name, rows)[0]
# 3. delete
delete_num = 3
self.delete(client, collection_name, ids=[i for i in range(delete_num)], filter=f"id < {delete_num}",
check_task=CheckTasks.err_res,
check_items={"err_code": 1,
"err_msg": "Ambiguous filter parameter, "
"only one deletion condition can be specified."})
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.skip(reason="pymilvus issue 1869")
def test_milvus_client_delete_with_invalid_id_type(self):
"""
target: test delete (high level api)
method: create connection, collection, insert delete, and search
expected: search/query successfully without deleted data
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Strong")
# 2. delete
self.delete(client, collection_name, ids=0,
check_task=CheckTasks.err_res,
check_items={"err_code": 1,
"err_msg": "expr cannot be empty"})
@pytest.mark.tags(CaseLabel.L2)
def test_milvus_client_delete_with_not_all_required_params(self):
"""
target: test delete (high level api)
method: create connection, collection, insert delete, and search
expected: search/query successfully without deleted data
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Strong")
# 2. delete
self.delete(client, collection_name,
check_task=CheckTasks.err_res,
check_items={"err_code": 999,
"err_msg": "The type of expr must be string ,but <class 'NoneType'> is given."})
_json_path_index_params = [
("INVERTED", "BOOL"),
("INVERTED", "DOUBLE"),
("INVERTED", "VARCHAR"),
("INVERTED", "JSON"),
("STL_SORT", "DOUBLE"),
("STL_SORT", "VARCHAR"),
("BITMAP", "BOOL"),
("BITMAP", "VARCHAR"),
]
class TestMilvusClientDeleteValid(TestMilvusClientV2Base):
""" Test case of search interface """
@pytest.fixture(scope="function", params=[False, True])
def auto_id(self, request):
yield request.param
@pytest.fixture(scope="function", params=["COSINE", "L2"])
def metric_type(self, request):
yield request.param
@pytest.fixture(scope="function", params=_json_path_index_params, ids=[f"{t[0]}_{t[1]}" for t in _json_path_index_params])
def json_index_params(self, request):
yield request.param
@pytest.fixture(scope="function")
def supported_varchar_scalar_index(self, json_index_params):
yield json_index_params[0]
@pytest.fixture(scope="function")
def supported_json_cast_type(self, json_index_params):
yield json_index_params[1]
"""
******************************************************************
# The following are valid base cases
******************************************************************
"""
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_delete_with_ids(self):
"""
target: test delete (high level api)
method: create connection, collection, insert delete, and search
expected: search/query successfully without deleted data
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Strong")
# 2. insert
default_nb = 1000
rng = np.random.default_rng(seed=19530)
rows = [{default_primary_key_field_name: i, default_vector_field_name: list(rng.random((1, default_dim))[0]),
default_float_field_name: i * 1.0, default_string_field_name: str(i)} for i in range(default_nb)]
pks = self.insert(client, collection_name, rows)[0]
# 3. delete
delete_num = 3
self.delete(client, collection_name, ids=[i for i in range(delete_num)])
# 4. search
vectors_to_search = rng.random((1, default_dim))
insert_ids = [i for i in range(default_nb)]
for insert_id in range(delete_num):
if insert_id in insert_ids:
insert_ids.remove(insert_id)
limit = default_nb - delete_num
self.search(client, collection_name, vectors_to_search, limit=default_nb,
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": len(vectors_to_search),
"pk_name": default_primary_key_field_name,
"ids": insert_ids,
"limit": limit})
# 5. query
self.query(client, collection_name, filter=default_search_exp,
check_task=CheckTasks.check_query_results,
check_items={exp_res: rows[delete_num:],
"with_vec": True,
"pk_name": default_primary_key_field_name})
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_delete_with_filters(self):
"""
target: test delete (high level api)
method: create connection, collection, insert delete, and search
expected: search/query successfully without deleted data
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Strong")
# 2. insert
default_nb = 1000
rng = np.random.default_rng(seed=19530)
rows = [{default_primary_key_field_name: i, default_vector_field_name: list(rng.random((1, default_dim))[0]),
default_float_field_name: i * 1.0, default_string_field_name: str(i)} for i in range(default_nb)]
pks = self.insert(client, collection_name, rows)[0]
# 3. delete
delete_num = 3
self.delete(client, collection_name, filter=f"id < {delete_num}")
# 4. search
vectors_to_search = rng.random((1, default_dim))
insert_ids = [i for i in range(default_nb)]
for insert_id in range(delete_num):
if insert_id in insert_ids:
insert_ids.remove(insert_id)
limit = default_nb - delete_num
self.search(client, collection_name, vectors_to_search, limit=default_nb,
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": len(vectors_to_search),
"ids": insert_ids,
"pk_name": default_primary_key_field_name,
"limit": limit})
# 5. query
self.query(client, collection_name, filter=default_search_exp,
check_task=CheckTasks.check_query_results,
check_items={exp_res: rows[delete_num:],
"with_vec": True,
"pk_name": default_primary_key_field_name})
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_delete_with_filters_nullable_vector_field(self):
"""
target: test delete with filters on nullable vector field
method: create collection with nullable vector field,
insert data with nullable vector field, delete with filters on nullable vector field
expected: delete successfully
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
# 1. create collection
dim = 32
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(default_primary_key_field_name, DataType.INT64, is_primary=True, auto_id=False)
schema.add_field(default_vector_field_name, DataType.FLOAT_VECTOR, dim=dim, nullable=True)
schema.add_field(default_float_field_name, DataType.FLOAT, nullable=True)
self.create_collection(client, collection_name, schema=schema)
# 2. insert data
rows = [{
default_primary_key_field_name: i,
default_vector_field_name: cf.gen_vectors(1, dim=dim)[0] if i % 2 == 0 else None,
default_float_field_name: i * 1.0,
} for i in range(default_nb)]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
# 3. delete 500 random by ids
ids_to_delete = random.sample(range(default_nb), 500)
self.delete(client, collection_name, filter=f"{default_primary_key_field_name} in {ids_to_delete}")
self.flush(client, collection_name)
# create index and load
index_params = self.prepare_index_params(client)[0]
index_params.add_index(default_vector_field_name, index_type="FLAT", metric_type="L2")
self.create_index(client, collection_name, index_params=index_params)
self.load_collection(client, collection_name)
# 4. query count(*)
self.query(client, collection_name, filter="", output_fields=["count(*)"],
check_task=CheckTasks.check_query_results,
check_items={"exp_res": [{"count(*)": default_nb - len(ids_to_delete)}],
"pk_name": default_primary_key_field_name})
# delete by float filter
filter = f"{default_float_field_name} < {default_nb / 2}"
self.delete(client, collection_name, filter=filter)
self.flush(client, collection_name)
# 5. query count(*)
expect_count = default_nb//2 - len([i for i in ids_to_delete if i >= default_nb / 2])
self.query(client, collection_name, filter="", output_fields=["count(*)"],
check_task=CheckTasks.check_query_results,
check_items={"exp_res": [{"count(*)": expect_count}],
"pk_name": default_primary_key_field_name})
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("add_field", [True, False])
def test_milvus_client_delete_with_filters_partition(self, add_field):
"""
target: test delete (high level api)
method: create connection, collection, insert delete, and search
expected: search/query successfully without deleted data
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
# 1. create collection
self.create_collection(client, collection_name, default_dim, consistency_level="Strong")
# 2. insert
default_nb = 1000
rng = np.random.default_rng(seed=19530)
rows = [
{
default_primary_key_field_name: i,
default_vector_field_name: list(rng.random((1, default_dim))[0]),
default_float_field_name: i * 1.0,
default_string_field_name: str(i),
**({"field_new": "default"} if add_field else {})
}
for i in range(default_nb)
]
if add_field:
self.add_collection_field(client, collection_name, field_name="field_new", data_type=DataType.VARCHAR,
nullable=True, max_length=64)
pks = self.insert(client, collection_name, rows)[0]
# 3. get partition lists
partition_names = self.list_partitions(client, collection_name)
# 4. delete
delete_num = 3
filter = f"id < {delete_num} "
if add_field:
filter += "and field_new == 'default'"
self.delete(client, collection_name, filter=filter, partition_names=partition_names)
# 5. search
vectors_to_search = rng.random((1, default_dim))
insert_ids = [i for i in range(default_nb)]
for insert_id in range(delete_num):
if insert_id in insert_ids:
insert_ids.remove(insert_id)
limit = default_nb - delete_num
self.search(client, collection_name, vectors_to_search, limit=default_nb,
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": len(vectors_to_search),
"ids": insert_ids,
"pk_name": default_primary_key_field_name,
"limit": limit})
# 6. query
self.query(client, collection_name, filter=default_search_exp,
check_task=CheckTasks.check_query_results,
check_items={exp_res: rows[delete_num:],
"with_vec": True,
"pk_name": default_primary_key_field_name})
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("enable_dynamic_field", [True, False])
@pytest.mark.parametrize("is_flush", [True, False])
@pytest.mark.parametrize("is_release", [True, False])
def test_milvus_client_delete_with_filters_json_path_index(self, enable_dynamic_field, supported_varchar_scalar_index,
supported_json_cast_type, is_flush, is_release):
"""
target: test delete after json path index created
method: create connection, collection, index, insert, delete, and search
Step: 1. create schema
2. prepare index_params with vector and all the json path index params
3. create collection with the above schema and index params
4. insert
5. flush if specified
6. release collection if specified
7. load collection if specified
8. delete with expression on json path
9. search and query to check that the deleted entities not searched
expected: Delete and search/query successfully
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
# 1. create collection
json_field_name = "my_json"
schema = self.create_schema(client, enable_dynamic_field=enable_dynamic_field)[0]
schema.add_field(default_primary_key_field_name, DataType.INT64, is_primary=True, auto_id=False)
schema.add_field(default_vector_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
schema.add_field(default_float_field_name, DataType.FLOAT)
schema.add_field(default_string_field_name, DataType.VARCHAR, max_length=64)
if not enable_dynamic_field:
schema.add_field(json_field_name, DataType.JSON)
index_params = self.prepare_index_params(client)[0]
index_params.add_index(field_name=default_vector_field_name, index_type="AUTOINDEX", metric_type="L2")
index_params.add_index(field_name=json_field_name, index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type, "json_path": f"{json_field_name}['a']['b']"})
index_params.add_index(field_name=json_field_name,
index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a']"})
index_params.add_index(field_name=json_field_name,
index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}"})
index_params.add_index(field_name=json_field_name,
index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a'][0]['b']"})
index_params.add_index(field_name=json_field_name,
index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a'][0]"})
self.create_collection(client, collection_name, schema=schema,
index_params=index_params, metric_type="L2")
# 2. insert
default_nb = 1000
rng = np.random.default_rng(seed=19530)
rows = [{default_primary_key_field_name: i, default_vector_field_name: list(rng.random((1, default_dim))[0]),
default_float_field_name: i * 1.0, default_string_field_name: str(i),
json_field_name: {'a': {'b': i}}} for i in range(default_nb)]
pks = self.insert(client, collection_name, rows)[0]
if is_flush:
self.flush(client, collection_name)
if is_release:
self.release_collection(client, collection_name)
self.load_collection(client, collection_name)
# 3. delete
delete_num = 3
self.delete(client, collection_name, filter=f"{json_field_name}['a']['b'] < {delete_num}")
# 4. search
vectors_to_search = rng.random((1, default_dim))
insert_ids = [i for i in range(default_nb)]
for insert_id in range(delete_num):
if insert_id in insert_ids:
insert_ids.remove(insert_id)
limit = default_nb - delete_num
self.search(client, collection_name, vectors_to_search, limit=default_nb,
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": len(vectors_to_search),
"ids": insert_ids,
"pk_name": default_primary_key_field_name,
"limit": limit})
# 5. query
self.query(client, collection_name, filter=default_search_exp,
check_task=CheckTasks.check_query_results,
check_items={exp_res: rows[delete_num:],
"with_vec": True,
"pk_name": default_primary_key_field_name})
self.drop_collection(client, collection_name)
@@ -0,0 +1,411 @@
import math
import pytest
import time
from check import param_check as pc
from common.common_type import CaseLabel, CheckTasks
from common import common_func as cf
from common import common_type as ct
from utils.util_log import test_log as log
from utils.util_pymilvus import *
from base.client_v2_base import TestMilvusClientV2Base
from pymilvus import DataType
# Test parameters
default_nb = ct.default_nb
default_limit = ct.default_limit
default_search_exp = "id >= 0"
class TestMilvusClientE2E(TestMilvusClientV2Base):
""" Test case of end-to-end interface """
@pytest.mark.tags(CaseLabel.L0)
@pytest.mark.parametrize("flush_enable", [True, False])
@pytest.mark.parametrize("scalar_index_enable", [True, False])
def test_milvus_client_e2e_default(self, flush_enable, scalar_index_enable):
"""
target: test full E2E lifecycle with all nullable scalar types and nullable vector
method: 1. create collection with nullable fields (bool, int8/16/32/64, float, double, varchar, json, array, vector)
2. insert 6000 rows (2 batches × 3000) with ~20% nulls
3. create vector index + optional scalar indexes
4. search with COSINE metric, verify distance ordering and no NaN (nullable vector)
5. query with filters on each scalar type: null/not-null/comparison/range/like/in
6. delete all data, verify search and query return empty
expected: all search/query results match locally computed expected data;
no NaN distances from nullable vector; deletion fully effective
"""
client = self._client()
dim = 8
vector_type = DataType.FLOAT_VECTOR
# 1. Create collection with custom schema
collection_name = cf.gen_collection_name_by_testcase_name()
schema = self.create_schema(client, enable_dynamic_field=False)[0]
# Primary key and vector field
schema.add_field("id", DataType.INT64, is_primary=True, auto_id=False)
schema.add_field("vector", vector_type, dim=dim, nullable=True)
# Boolean type
schema.add_field("bool_field", DataType.BOOL, nullable=True)
# Integer types
schema.add_field("int8_field", DataType.INT8, nullable=True)
schema.add_field("int16_field", DataType.INT16, nullable=True)
schema.add_field("int32_field", DataType.INT32, nullable=True)
schema.add_field("int64_field", DataType.INT64, nullable=True)
# Float types
schema.add_field("float_field", DataType.FLOAT, nullable=True)
schema.add_field("double_field", DataType.DOUBLE, nullable=True)
# String type
schema.add_field("varchar_field", DataType.VARCHAR, max_length=65535, nullable=True)
# JSON type
schema.add_field("json_field", DataType.JSON, nullable=True)
# Array type
schema.add_field("array_field", DataType.ARRAY, element_type=DataType.FLOAT, max_capacity=12, nullable=True)
# Create collection
self.create_collection(client, collection_name, schema=schema)
# 2. Insert data with null values for nullable fields
num_inserts = 2 # 2 batches to cover sealed + growing scenarios
total_rows = []
for i in range(num_inserts):
data = cf.gen_row_data_by_schema(nb=default_nb, schema=schema, start=i * default_nb)
self.insert(client, collection_name, data)
total_rows.extend(data)
log.info(f"Total inserted {num_inserts * default_nb} entities")
if flush_enable:
self.flush(client, collection_name)
log.info("Flush enabled: executing flush operation")
# Create index parameters
index_params = self.prepare_index_params(client)[0]
index_params.add_index("vector", metric_type="COSINE")
# Add autoindex for scalar fields if enabled
if scalar_index_enable:
index_params.add_index(field_name="int8_field", index_type="AUTOINDEX")
index_params.add_index(field_name="int16_field", index_type="AUTOINDEX")
index_params.add_index(field_name="int32_field", index_type="AUTOINDEX")
index_params.add_index(field_name="int64_field", index_type="AUTOINDEX")
index_params.add_index(field_name="float_field", index_type="AUTOINDEX")
index_params.add_index(field_name="double_field", index_type="AUTOINDEX")
index_params.add_index(field_name="varchar_field", index_type="AUTOINDEX")
index_params.add_index(field_name="array_field", index_type="AUTOINDEX")
# 3. create index
self.create_index(client, collection_name, index_params)
# Verify scalar indexes are created if enabled
indexes = self.list_indexes(client, collection_name)[0]
log.info(f"Created indexes: {indexes}")
expected_scalar_indexes = ["int8_field", "int16_field", "int32_field", "int64_field",
"float_field", "double_field", "varchar_field", "array_field"]
if scalar_index_enable:
for field in expected_scalar_indexes:
assert field in indexes, f"Scalar index not created for field: {field}"
else:
for field in expected_scalar_indexes:
assert field not in indexes, f"Scalar index should not be created for field: {field}"
# 4. Load collection
t0 = time.time()
self.load_collection(client, collection_name)
t1 = time.time()
log.info(f"Load collection cost {t1 - t0:.4f} seconds")
# 5. Search
t0 = time.time()
vectors_to_search = cf.gen_vectors(1, dim, vector_data_type=vector_type)
search_params = {"metric_type": "COSINE", "params": {"nprobe": 100}}
search_res, _ = self.search(
client,
collection_name,
vectors_to_search,
anns_field="vector",
search_params=search_params,
limit=default_limit,
output_fields=['*'],
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": len(vectors_to_search),
"pk_name": "id",
"limit": default_limit,
"metric": "COSINE"}
)
# Verify no NaN distances (nullable vector leak detection)
for hits in search_res:
for hit in hits:
assert not math.isnan(hit["distance"]), \
f"NaN distance found in search result, pk={hit['id']}"
t1 = time.time()
log.info(f"Search cost {t1 - t0:.4f} seconds")
# 6. Query with filters on each scalar field
t0 = time.time()
# Data-driven query cases: (filter_string, predicate_lambda, with_vec, description)
query_cases = [
# Boolean field (with_vec=False: skip nullable vector comparison in check)
("bool_field == true",
lambda r: r["bool_field"] is not None and r["bool_field"] is True,
False, "bool true"),
# Int8: null or < 10
("int8_field is null || int8_field < 10",
lambda r: r["int8_field"] is None or r["int8_field"] < 10,
True, "int8 null or < 10"),
# Int16: range [100, 200)
("100 <= int16_field < 200",
lambda r: r["int16_field"] is not None and 100 <= r["int16_field"] < 200,
True, "int16 range [100, 200)"),
# Int32: in set
("int32_field in [1,2,5,6]",
lambda r: r["int32_field"] is not None and r["int32_field"] in [1, 2, 5, 6],
True, "int32 in [1,2,5,6]"),
# Int64: range [4678, 5050)
("int64_field >= 4678 and int64_field < 5050",
lambda r: r["int64_field"] is not None and r["int64_field"] >= 4678 and r["int64_field"] < 5050,
True, "int64 range [4678, 5050)"),
# Float: (0.5, 0.7]
("float_field > 0.5 and float_field <= 0.7",
lambda r: r["float_field"] is not None and r["float_field"] > 0.5 and r["float_field"] <= 0.7,
True, "float (0.5, 0.7]"),
# Double: [0.5, 0.7]
("0.5 <=double_field <= 0.7",
lambda r: r["double_field"] is not None and 0.5 <= r["double_field"] <= 0.7,
True, "double [0.5, 0.7]"),
# Varchar: like prefix
('varchar_field like "varchar_1%"',
lambda r: r["varchar_field"] is not None and r["varchar_field"].startswith("varchar_1"),
True, "varchar like varchar_1%"),
# Varchar: is null
("varchar_field is null",
lambda r: r["varchar_field"] is None,
True, "varchar is null"),
# JSON: is null
("json_field is null",
lambda r: r["json_field"] is None,
True, "json is null"),
# Array: is null
("array_field is null",
lambda r: r["array_field"] is None,
True, "array is null"),
# Multiple fields all null
("varchar_field is null and json_field is null and array_field is null",
lambda r: r["varchar_field"] is None and r["json_field"] is None and r["array_field"] is None,
True, "multi fields all null"),
# Mix: varchar null and json not null
("varchar_field is null and json_field is not null",
lambda r: r["varchar_field"] is None and r["json_field"] is not None,
True, "varchar null and json not null"),
# Int8: not null and > 100
("int8_field is not null and int8_field > 100",
lambda r: r["int8_field"] is not None and r["int8_field"] > 100,
True, "int8 not null and > 100"),
# Int16: not null and < 100
("int16_field is not null and int16_field < 100",
lambda r: r["int16_field"] is not None and r["int16_field"] < 100,
True, "int16 not null and < 100"),
# Float: not null and (0.5, 0.7]
("float_field is not null and float_field > 0.5 and float_field <= 0.7",
lambda r: r["float_field"] is not None and r["float_field"] > 0.5 and r["float_field"] <= 0.7,
True, "float not null and (0.5, 0.7]"),
# Double: not null and <= 0.2
("double_field is not null and double_field <= 0.2",
lambda r: r["double_field"] is not None and r["double_field"] <= 0.2,
True, "double not null and <= 0.2"),
# Varchar: not null
("varchar_field is not null",
lambda r: r["varchar_field"] is not None,
True, "varchar not null"),
# JSON: not null and count < 15
("json_field is not null and json_field['count'] < 15",
lambda r: r["json_field"] is not None and r["json_field"]["count"] < 15,
True, "json not null and count < 15"),
# Array: not null and first element < 100
("array_field is not null and array_field[0] < 100",
lambda r: r["array_field"] is not None and r["array_field"][0] < 100,
True, "array not null and [0] < 100"),
# Multiple fields all not null
("varchar_field is not null and json_field is not null and array_field is not null",
lambda r: r["varchar_field"] is not None and r["json_field"] is not None and r["array_field"] is not None,
True, "multi fields all not null"),
# Complex: int32 null, float > 0.7, varchar not null
("int32_field is null and float_field > 0.7 and varchar_field is not null",
lambda r: (r["int32_field"] is None and
r["float_field"] is not None and r["float_field"] > 0.7 and
r["varchar_field"] is not None),
True, "int32 null and float > 0.7 and varchar not null"),
# Complex: varchar not null, int64 in [5, 15], float null
("varchar_field is not null and 5 <= int64_field <= 15 and float_field is null",
lambda r: (r["varchar_field"] is not None and
r["int64_field"] is not None and 5 <= r["int64_field"] <= 15 and
r["float_field"] is None),
True, "varchar not null and int64 [5,15] and float null"),
# Complex: int8 not null < 15, double null, varchar not null like varchar_2%
("int8_field is not null and int8_field < 15 and double_field is null and "
"varchar_field is not null and varchar_field like \"varchar_2%\"",
lambda r: (r["int8_field"] is not None and r["int8_field"] < 15 and
r["double_field"] is None and
r["varchar_field"] is not None and r["varchar_field"].startswith("varchar_2")),
True, "int8 < 15 and double null and varchar like varchar_2%"),
]
for filter_str, predicate, with_vec, desc in query_cases:
expected = [r for r in total_rows if predicate(r)]
log.info(f"query {desc}: filter={filter_str}, expected={len(expected)}")
self.query(
client,
collection_name,
filter=filter_str,
output_fields=['*'],
check_task=CheckTasks.check_query_results,
check_items={
"exp_res": expected,
"with_vec": with_vec,
"vector_type": vector_type,
"pk_name": "id"
}
)
t1 = time.time()
log.info(f"Query on all scalar fields cost {t1 - t0:.4f} seconds")
# 7. Delete data
t0 = time.time()
self.delete(client, collection_name, filter=default_search_exp)
t1 = time.time()
log.info(f"Delete cost {t1 - t0:.4f} seconds")
# 8. Verify deletion via query
self.query(
client,
collection_name,
filter=default_search_exp,
check_task=CheckTasks.check_query_results,
check_items={"exp_res": []}
)
# 9. Verify deletion via search — should return 0 results
self.search(
client,
collection_name,
vectors_to_search,
anns_field="vector",
search_params=search_params,
limit=default_limit,
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": len(vectors_to_search),
"pk_name": "id",
"limit": 0,
"metric": "COSINE"}
)
# 10. Cleanup
self.release_collection(client, collection_name)
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L0)
@pytest.mark.parametrize("flush_enable", [True, False])
def test_milvus_client_data_consistent(self, flush_enable):
"""
target: verify data consistency between inserted data and query_iterator results
method: 1. create collection with nullable scalar fields + array fields
2. insert 6000 rows (2 batches × 3000) with ~20% nulls
3. create COSINE index, load, search with metric verification
4. use query_iterator to retrieve all rows
5. compare query_iterator results with original inserted data (epsilon-aware)
expected: query_iterator results exactly match inserted data (order-independent, float-epsilon-tolerant)
"""
client = self._client()
dim = 28
vector_type = DataType.FLOAT_VECTOR
# 1. Create collection with custom schema
collection_name = cf.gen_collection_name_by_testcase_name()
schema = self.create_schema(client, enable_dynamic_field=False)[0]
# Primary key and vector field
schema.add_field("id", DataType.INT64, is_primary=True, auto_id=False)
schema.add_field("vector", vector_type, dim=dim)
# Boolean type
schema.add_field("bool_field", DataType.BOOL, nullable=True)
# Integer types
schema.add_field("int16_field", DataType.INT16, nullable=True)
schema.add_field("int32_field", DataType.INT32, nullable=True)
schema.add_field("int64_field", DataType.INT64, nullable=True)
# Float types
schema.add_field("float_field", DataType.FLOAT, nullable=True)
schema.add_field("double_field", DataType.DOUBLE, nullable=True)
# String type
schema.add_field("varchar_field", DataType.VARCHAR, max_length=200, nullable=True)
# JSON type
schema.add_field("json_field", DataType.JSON, nullable=True)
# Array float type
schema.add_field("array_float_field", DataType.ARRAY, element_type=DataType.FLOAT, max_capacity=15, nullable=True)
# Array varchar type
schema.add_field("array_varchar_field", DataType.ARRAY, element_type=DataType.VARCHAR, max_capacity=15, max_length=100, nullable=True)
# Create collection
self.create_collection(client, collection_name, schema=schema)
# 2. Insert data with null values for nullable fields
num_inserts = 2 # 2 batches to cover sealed + growing scenarios
total_rows = []
for i in range(num_inserts):
data = cf.gen_row_data_by_schema(nb=default_nb, schema=schema, start=i * default_nb)
self.insert(client, collection_name, data)
total_rows.extend(data)
log.info(f"Total inserted {num_inserts * default_nb} entities")
if flush_enable:
self.flush(client, collection_name)
log.info("Flush enabled: executing flush operation")
# Create index parameters
index_params = self.prepare_index_params(client)[0]
index_params.add_index("vector", metric_type="COSINE")
# 3. create index
self.create_index(client, collection_name, index_params)
# 4. Load collection
self.load_collection(client, collection_name)
# 5. Search
vectors_to_search = cf.gen_vectors(1, dim, vector_data_type=vector_type)
search_params = {"metric_type": "COSINE", "params": {"nprobe": 100}}
search_res, _ = self.search(
client,
collection_name,
vectors_to_search,
anns_field="vector",
search_params=search_params,
limit=default_limit,
output_fields=['*'],
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": len(vectors_to_search),
"pk_name": "id",
"limit": default_limit,
"metric": "COSINE"}
)
# use query iterator to get all the data and compare with the inserted original data
query_total_rows = []
query_iterator = self.query_iterator(client, collection_name, output_fields=["*"])[0]
while True:
res = query_iterator.next()
if len(res) == 0:
log.info("search iteration finished, close")
query_iterator.close()
break
query_total_rows.extend(res)
# 6. Query with filters on each scalar field
t1 = time.time()
compare_res = pc.compare_lists_with_epsilon_ignore_dict_order(a=query_total_rows, b=total_rows)
assert compare_res, "query result is not consistent with the inserted original data"
t2 = time.time()
log.info(f"Query results compare costs {t2 - t1:.4f} seconds")
# 7. Cleanup
self.release_collection(client, collection_name)
self.drop_collection(client, collection_name)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,637 @@
"""
ForceMerge Compaction Test Cases
L3 tests require Milvus configuration changes to trigger actual force merge:
dataCoord:
segment:
maxSize: 64 # MB, default is 1024
compaction:
enableAutoCompaction: false # Disable auto compaction
With maxSize=64MB and auto compaction disabled, small data volumes can trigger
force merge compaction manually without interference from auto compaction.
"""
import time
import numpy as np
import pytest
from base.client_v2_base import TestMilvusClientV2Base
from common import common_func as cf
from common import common_type as ct
from common.common_type import CaseLabel, CheckTasks
from common.constants import * # noqa: F403
from pymilvus import DataType
from utils.util_log import test_log as log
from utils.util_pymilvus import * # noqa: F403
prefix = "client_force_merge"
epsilon = ct.epsilon
default_nb = ct.default_nb
default_nb_medium = ct.default_nb_medium
default_nq = ct.default_nq
default_dim = ct.default_dim
default_limit = ct.default_limit
default_search_exp = "id >= 0"
exp_res = "exp_res"
default_search_field = ct.default_float_vec_field_name
default_search_params = ct.default_search_params
default_primary_key_field_name = "id"
default_vector_field_name = "vector"
default_float_field_name = ct.default_float_field_name
default_string_field_name = ct.default_string_field_name
# ForceMerge specific constants
max_int64 = (1 << 63) - 1
auto_target_size_mb = max_int64 // (1024 * 1024) + 1 # Triggers server auto target-size mode.
default_max_size_mb = 1024 # Default segment max size in MB
class TestMilvusClientForceMergeInvalid(TestMilvusClientV2Base):
"""Test cases for ForceMerge with invalid parameters"""
"""
******************************************************************
# The following are invalid base cases
******************************************************************
"""
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("target_size", [-1, -100])
def test_force_merge_invalid_target_size_negative(self, target_size):
"""
target: test ForceMerge with negative target_size
method: create collection, call compact with negative target_size
expected: Raise exception
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
# 1. create collection
self.create_collection(client, collection_name, default_dim)
# 2. compact with invalid target_size
error = {ct.err_code: 1, ct.err_msg: "target_size"}
self.compact(
client,
collection_name,
target_size=target_size,
check_task=CheckTasks.err_res,
check_items=error,
)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("target_size", [100, 512, 1000])
def test_force_merge_target_size_less_than_max_size(self, target_size):
"""
target: test ForceMerge with target_size less than config maxSize
method: create collection, call compact with target_size < 1024 MB
expected: Raise exception
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
# 1. create collection
self.create_collection(client, collection_name, default_dim)
# 2. compact with target_size less than maxSize
error = {ct.err_code: 1100, ct.err_msg: "targetSize"}
self.compact(
client,
collection_name,
target_size=target_size,
check_task=CheckTasks.err_res,
check_items=error,
)
@pytest.mark.tags(CaseLabel.L1)
def test_force_merge_nonexistent_collection(self):
"""
target: test ForceMerge with non-existent collection
method: call compact on a collection that doesn't exist
expected: Raise exception
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
error = {ct.err_code: 100, ct.err_msg: "collection not found"}
self.compact(
client,
collection_name,
target_size=2048,
check_task=CheckTasks.err_res,
check_items=error,
)
class TestMilvusClientForceMergeValid(TestMilvusClientV2Base):
"""Test cases for ForceMerge with valid parameters"""
"""
******************************************************************
# The following are valid base cases
******************************************************************
"""
@pytest.mark.tags(CaseLabel.L3)
def test_force_merge_default_target_size(self):
"""
target: test ForceMerge with default target_size (0 or not passed)
method: create collection, insert data, flush, compact without target_size
expected: Compaction completes successfully
note: L3 - requires config change (segment.maxSize=64MB) to trigger actual force merge
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
# 1. create collection
self.create_collection(client, collection_name, dim)
# 2. insert data
rng = np.random.default_rng(seed=19530)
rows = [
{
default_primary_key_field_name: i,
default_vector_field_name: list(rng.random((1, dim))[0]),
}
for i in range(default_nb)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
# 3. compact with default target_size (not passed)
compact_id = self.compact(client, collection_name)[0]
# 4. wait for compaction to complete
cost = 180
start = time.time()
while True:
time.sleep(1)
res = self.get_compaction_state(client, compact_id)[0]
log.info(f"Compaction state: {res}")
if res == "Completed":
break
if time.time() - start > cost:
raise Exception(f"Compaction cost more than {cost}s")
log.info("ForceMerge with default target_size completed successfully")
@pytest.mark.tags(CaseLabel.L3)
def test_force_merge_explicit_target_size(self):
"""
target: test ForceMerge with explicit target_size (2048 MB)
method: create collection, insert data, flush, compact with target_size=2048
expected: Compaction completes successfully
note: L3 - requires config change (segment.maxSize=64MB) to trigger actual force merge
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
# 1. create collection
self.create_collection(client, collection_name, dim)
# 2. insert data
rng = np.random.default_rng(seed=19530)
rows = [
{
default_primary_key_field_name: i,
default_vector_field_name: list(rng.random((1, dim))[0]),
}
for i in range(default_nb)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
# 3. compact with explicit target_size
target_size = 2048 # 2GB
compact_id = self.compact(client, collection_name, target_size=target_size)[0]
# 4. wait for compaction to complete
cost = 180
start = time.time()
while True:
time.sleep(1)
res = self.get_compaction_state(client, compact_id)[0]
log.info(f"Compaction state: {res}")
if res == "Completed":
break
if time.time() - start > cost:
raise Exception(f"Compaction cost more than {cost}s")
log.info(f"ForceMerge with target_size={target_size}MB completed successfully")
@pytest.mark.tags(CaseLabel.L3)
def test_force_merge_auto_target_size(self):
"""
target: test ForceMerge with automatic target_size
method: create collection, insert data, flush, compact with target_size above int64-safe MB threshold
expected: Compaction completes successfully with auto-calculated optimal size
note: L3 - requires config change (segment.maxSize=64MB) to trigger actual force merge
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
# 1. create collection
self.create_collection(client, collection_name, dim)
# 2. insert data
rng = np.random.default_rng(seed=19530)
rows = [
{
default_primary_key_field_name: i,
default_vector_field_name: list(rng.random((1, dim))[0]),
}
for i in range(default_nb)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
# 3. compact with auto target_size
compact_id = self.compact(client, collection_name, target_size=auto_target_size_mb)[0]
# 4. wait for compaction to complete
cost = 180
start = time.time()
while True:
time.sleep(1)
res = self.get_compaction_state(client, compact_id)[0]
log.info(f"Compaction state: {res}")
if res == "Completed":
break
if time.time() - start > cost:
raise Exception(f"Compaction cost more than {cost}s")
log.info("ForceMerge with auto target_size (max_int64) completed successfully")
@pytest.mark.tags(CaseLabel.L1)
def test_force_merge_empty_collection(self):
"""
target: test ForceMerge on empty collection
method: create collection, compact with target_size
expected: Compaction completes successfully
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
# 1. create collection (empty)
self.create_collection(client, collection_name, dim)
# 2. compact with target_size on empty collection
compact_id = self.compact(client, collection_name, target_size=2048)[0]
# 3. wait for compaction to complete
cost = 60
start = time.time()
while True:
time.sleep(1)
res = self.get_compaction_state(client, compact_id)[0]
log.info(f"Compaction state: {res}")
if res == "Completed":
break
if time.time() - start > cost:
raise Exception(f"Compaction cost more than {cost}s")
log.info("ForceMerge on empty collection completed successfully")
@pytest.mark.tags(CaseLabel.L3)
def test_force_merge_with_multiple_segments(self):
"""
target: test ForceMerge with multiple segments
method: create collection, insert data in batches to create multiple segments,
flush, compact with target_size
expected: Segments merged, fewer segments after compaction
note: L3 - requires config change (segment.maxSize=64MB) to trigger actual force merge
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
# 1. create collection
self.create_collection(client, collection_name, dim)
# 2. insert data in multiple batches to create multiple segments
rng = np.random.default_rng(seed=19530)
batch_size = default_nb
num_batches = 5
for batch in range(num_batches):
rows = [
{
default_primary_key_field_name: batch * batch_size + i,
default_vector_field_name: list(rng.random((1, dim))[0]),
}
for i in range(batch_size)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
log.info(f"Inserted batch {batch + 1}/{num_batches}")
# 3. compact with target_size
target_size = 2048 # 2GB
compact_id = self.compact(client, collection_name, target_size=target_size)[0]
# 4. wait for compaction to complete
cost = 300
start = time.time()
while True:
time.sleep(1)
res = self.get_compaction_state(client, compact_id)[0]
log.info(f"Compaction state: {res}")
if res == "Completed":
break
if time.time() - start > cost:
raise Exception(f"Compaction cost more than {cost}s")
log.info("ForceMerge with multiple segments completed successfully")
@pytest.mark.tags(CaseLabel.L3)
def test_force_merge_search_after_merge(self):
"""
target: test search works correctly after ForceMerge
method: create collection, insert data, flush, compact, load, search
expected: Search returns correct results
note: L3 - requires config change (segment.maxSize=64MB) to trigger actual force merge
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
# 1. create collection
self.create_collection(client, collection_name, dim)
# 2. insert data
rng = np.random.default_rng(seed=19530)
nb = default_nb
rows = [
{
default_primary_key_field_name: i,
default_vector_field_name: list(rng.random((1, dim))[0]),
}
for i in range(nb)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
# 3. compact with target_size
target_size = 2048
compact_id = self.compact(client, collection_name, target_size=target_size)[0]
# 4. wait for compaction to complete
cost = 180
start = time.time()
while True:
time.sleep(1)
res = self.get_compaction_state(client, compact_id)[0]
log.info(f"Compaction state: {res}")
if res == "Completed":
break
if time.time() - start > cost:
raise Exception(f"Compaction cost more than {cost}s")
# 5. search
search_vectors = rng.random((1, dim))
search_res = self.search(
client,
collection_name,
list(search_vectors),
limit=10,
output_fields=[default_primary_key_field_name],
)[0]
log.info(f"Search results: {search_res}")
assert len(search_res) == 1
assert len(search_res[0]) == 10
log.info(f"Search after ForceMerge completed successfully with {len(search_res[0])} results")
@pytest.mark.tags(CaseLabel.L3)
def test_force_merge_with_clustering_key(self):
"""
target: test ForceMerge with clustering key
method: create collection with clustering key, insert data, compact with is_clustering=True
expected: Compaction completes successfully
note: L3 - requires config change (segment.maxSize=64MB) to trigger actual force merge
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
# 1. create collection with clustering key
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(
default_primary_key_field_name,
DataType.INT64,
is_primary=True,
auto_id=False,
)
schema.add_field(default_vector_field_name, DataType.FLOAT_VECTOR, dim=dim)
schema.add_field(
default_string_field_name,
DataType.VARCHAR,
max_length=64,
is_clustering_key=True,
)
index_params = self.prepare_index_params(client)[0]
index_params.add_index(default_vector_field_name, metric_type="COSINE")
self.create_collection(
client,
collection_name,
dimension=dim,
schema=schema,
index_params=index_params,
)
# 2. insert data
rng = np.random.default_rng(seed=19530)
rows = [
{
default_primary_key_field_name: i,
default_vector_field_name: list(rng.random((1, dim))[0]),
default_string_field_name: f"str_{i}",
}
for i in range(default_nb)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
# 3. compact with is_clustering=True and target_size
target_size = 2048
compact_id = self.compact(client, collection_name, is_clustering=True, target_size=target_size)[0]
# 4. wait for compaction to complete
cost = 180
start = time.time()
while True:
time.sleep(1)
res = self.get_compaction_state(client, compact_id, is_clustering=True)[0]
log.info(f"Compaction state: {res}")
if res == "Completed":
break
if time.time() - start > cost:
raise Exception(f"Compaction cost more than {cost}s")
log.info("ForceMerge with clustering key completed successfully")
@pytest.mark.tags(CaseLabel.L3)
def test_force_merge_verify_segment_count(self):
"""
target: test ForceMerge reduces segment count
method: create collection, insert data in batches, verify segment count before/after
expected: Fewer segments after ForceMerge
note: L3 - requires config change (segment.maxSize=64MB) to trigger actual force merge
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
# 1. create collection
self.create_collection(client, collection_name, dim)
# 2. insert data in multiple batches
rng = np.random.default_rng(seed=19530)
batch_size = default_nb
num_batches = 5
for batch in range(num_batches):
rows = [
{
default_primary_key_field_name: batch * batch_size + i,
default_vector_field_name: list(rng.random((1, dim))[0]),
}
for i in range(batch_size)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
# 3. reload and get stable segment count before compaction
assert self.wait_for_index_ready(client, collection_name, default_vector_field_name, timeout=300)
self.release_collection(client, collection_name)
self.load_collection(client, collection_name)
segments_before = client.list_loaded_segments(collection_name)
segment_count_before = len(segments_before)
log.info(f"Segment count before ForceMerge: {segment_count_before}")
# 4. compact with target_size
target_size = 2048
compact_id = self.compact(client, collection_name, target_size=target_size)[0]
# 5. wait for compaction to complete
cost = 300
start = time.time()
while True:
time.sleep(1)
res = self.get_compaction_state(client, compact_id)[0]
if res == "Completed":
break
if time.time() - start > cost:
raise Exception(f"Compaction cost more than {cost}s")
# 6. wait for the compacted segment index, then reload to get updated segment info
assert self.wait_for_index_ready(client, collection_name, default_vector_field_name, timeout=300)
self.release_collection(client, collection_name)
self.load_collection(client, collection_name)
segments_after = client.list_loaded_segments(collection_name)
segment_count_after = len(segments_after)
log.info(f"Segment count after ForceMerge: {segment_count_after}")
# 7. verify segment count reduced (or at least not increased)
assert segment_count_after <= segment_count_before, (
f"Expected fewer segments after ForceMerge, got {segment_count_after} >= {segment_count_before}"
)
log.info(f"ForceMerge reduced segments from {segment_count_before} to {segment_count_after}")
@pytest.mark.tags(CaseLabel.L3)
def test_force_merge_max_int64_overflow(self):
"""
target: test ForceMerge with huge targetSize doesn't cause overflow
method: create collection, insert data, compact with target_size above int64-safe MB threshold
expected: Compaction completes successfully (auto-calculate mode)
note: L3 - This verifies PR #47327 fix for integer overflow when
targetSizeBytes = targetSize * 1024 * 1024
Requires config change (segment.maxSize=64MB) to trigger actual force merge
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
# 1. create collection
self.create_collection(client, collection_name, dim)
# 2. insert data in batches to create segments
rng = np.random.default_rng(seed=19530)
for batch in range(3):
rows = [
{
default_primary_key_field_name: batch * 1000 + i,
default_vector_field_name: list(rng.random((1, dim))[0]),
}
for i in range(1000)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
log.info(f"Inserted batch {batch + 1}/3")
# 3. compact with huge target_size (should trigger auto-calculate mode)
# Before fix: would fail with overflow error
# After fix: should succeed
compact_id = self.compact(client, collection_name, target_size=auto_target_size_mb)[0]
# 4. wait for compaction to complete
cost = 180
start = time.time()
while True:
time.sleep(1)
res = self.get_compaction_state(client, compact_id)[0]
log.info(f"Compaction state: {res}")
if res == "Completed":
break
if time.time() - start > cost:
raise Exception(f"Compaction cost more than {cost}s")
log.info("ForceMerge with max_int64 target_size completed (overflow fix verified)")
@pytest.mark.tags(CaseLabel.L3)
def test_force_merge_algorithm_selection_under_threshold(self):
"""
target: test ForceMerge uses maxFullSegmentsGrouping when segment count <= threshold
method: create collection, insert data to create 5 segments (< threshold 10),
trigger force merge with target_size
expected: Compaction completes, algorithm selection logged as maxFullSegmentsGrouping
note: L3 - Check Loki logs for 'using maxFullSegmentsGrouping algorithm'
Requires config change (segment.maxSize=64MB) to trigger actual force merge
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
num_segments = 5 # Under threshold (default 10)
# 1. create collection
self.create_collection(client, collection_name, dim)
# 2. insert data in batches to create multiple segments
rng = np.random.default_rng(seed=19530)
for batch in range(num_segments):
rows = [
{
default_primary_key_field_name: batch * 1000 + i,
default_vector_field_name: list(rng.random((1, dim))[0]),
}
for i in range(1000)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
log.info(f"Inserted batch {batch + 1}/{num_segments}")
# 3. compact with target_size to trigger ForceMerge
target_size = 2048
compact_id = self.compact(client, collection_name, target_size=target_size)[0]
log.info(f"ForceMerge triggered with {num_segments} segments (expect maxFullSegmentsGrouping)")
# 4. wait for compaction to complete
cost = 300
start = time.time()
while True:
time.sleep(1)
res = self.get_compaction_state(client, compact_id)[0]
log.info(f"Compaction state: {res}")
if res == "Completed":
break
if time.time() - start > cost:
raise Exception(f"Compaction cost more than {cost}s")
log.info(f"ForceMerge with {num_segments} segments completed (check logs for algorithm)")
@pytest.mark.tags(CaseLabel.L3)
def test_force_merge_algorithm_selection_over_threshold(self):
"""
target: test ForceMerge uses largerGroupingSegments when segment count > threshold
method: create collection, insert data to create 15 segments (> threshold 10),
trigger force merge with target_size
expected: Compaction completes, algorithm selection logged as largerGroupingSegments
note: L3 - Check Loki logs for 'using largerGroupingSegments algorithm'
Requires config change (segment.maxSize=64MB) to trigger actual force merge
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
num_segments = 15 # Over threshold (default 10)
# 1. create collection
self.create_collection(client, collection_name, dim)
# 2. insert data in batches to create multiple segments
rng = np.random.default_rng(seed=19530)
for batch in range(num_segments):
rows = [
{
default_primary_key_field_name: batch * 1000 + i,
default_vector_field_name: list(rng.random((1, dim))[0]),
}
for i in range(1000)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
log.info(f"Inserted batch {batch + 1}/{num_segments}")
# 3. compact with target_size to trigger ForceMerge
target_size = 2048
compact_id = self.compact(client, collection_name, target_size=target_size)[0]
log.info(f"ForceMerge triggered with {num_segments} segments (expect largerGroupingSegments)")
# 4. wait for compaction to complete
cost = 600 # Longer timeout for more segments
start = time.time()
while True:
time.sleep(1)
res = self.get_compaction_state(client, compact_id)[0]
log.info(f"Compaction state: {res}")
if res == "Completed":
break
if time.time() - start > cost:
raise Exception(f"Compaction cost more than {cost}s")
log.info(f"ForceMerge with {num_segments} segments completed (check logs for algorithm)")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,574 @@
# ruff: noqa: E712,E731,F401,F403,F405,F541,F841,I001,UP031,UP032,W291,W292,W293
# fmt: off
import pytest
import numpy as np
from base.client_v2_base import TestMilvusClientV2Base
from utils.util_log import test_log as log
from common import common_func as cf
from common import common_type as ct
from common.common_type import CaseLabel, CheckTasks
from utils.util_pymilvus import *
prefix = "client_insert"
epsilon = ct.epsilon
default_nb = ct.default_nb
default_nb_medium = ct.default_nb_medium
default_nq = ct.default_nq
default_dim = ct.default_dim
default_limit = ct.default_limit
default_search_exp = "id >= 0"
exp_res = "exp_res"
default_search_string_exp = "varchar >= \"0\""
default_search_mix_exp = "int64 >= 0 && varchar >= \"0\""
default_invaild_string_exp = "varchar >= 0"
default_json_search_exp = "json_field[\"number\"] >= 0"
perfix_expr = 'varchar like "0%"'
default_search_field = ct.default_float_vec_field_name
default_search_params = ct.default_search_params
default_primary_key_field_name = "id"
default_vector_field_name = "vector"
default_dynamic_field_name = "field_new"
default_float_field_name = ct.default_float_field_name
default_bool_field_name = ct.default_bool_field_name
default_string_field_name = ct.default_string_field_name
default_int32_array_field_name = ct.default_int32_array_field_name
default_string_array_field_name = ct.default_string_array_field_name
default_int32_field_name = ct.default_int32_field_name
default_int32_value = ct.default_int32_value
# Valid (index_type, json_cast_type) combinations for JSON path index
_json_path_index_params = [
("INVERTED", "BOOL"),
("INVERTED", "DOUBLE"),
("INVERTED", "VARCHAR"),
("INVERTED", "JSON"),
("STL_SORT", "DOUBLE"),
("STL_SORT", "VARCHAR"),
("BITMAP", "BOOL"),
("BITMAP", "VARCHAR"),
]
class TestMilvusClientInsertJsonPathIndexValid(TestMilvusClientV2Base):
""" Test case of insert interface """
@pytest.fixture(scope="function", params=_json_path_index_params, ids=[f"{t[0]}_{t[1]}" for t in _json_path_index_params])
def json_index_params(self, request):
yield request.param
@pytest.fixture(scope="function")
def supported_varchar_scalar_index(self, json_index_params):
yield json_index_params[0]
@pytest.fixture(scope="function")
def supported_json_cast_type(self, json_index_params):
yield json_index_params[1]
"""
******************************************************************
# The following are valid base cases
******************************************************************
"""
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("enable_dynamic_field", [True, False])
def test_milvus_client_insert_before_json_path_index(self, enable_dynamic_field, supported_json_cast_type,
supported_varchar_scalar_index):
"""
target: test insert and then create json path index
method: create json path index after insert
steps: 1. create schema
2. create collection
3. insert
4. prepare json path index params with parameter "json_cast_type" and "json_path"
5. create index
expected: insert and create json path index successfully
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
# 1. create collection
json_field_name = "my_json"
schema = self.create_schema(client, enable_dynamic_field=enable_dynamic_field)[0]
schema.add_field(default_primary_key_field_name, DataType.INT64, is_primary=True, auto_id=False)
schema.add_field(default_vector_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
schema.add_field(default_string_field_name, DataType.VARCHAR, max_length=64)
if not enable_dynamic_field:
schema.add_field(json_field_name, DataType.JSON)
index_params = self.prepare_index_params(client)[0]
index_params.add_index(default_vector_field_name, metric_type="COSINE")
self.create_collection(client, collection_name, schema=schema, index_params=index_params)
# 2. insert with different data distribution
vectors = cf.gen_vectors(default_nb+50, default_dim)
rows = [{default_primary_key_field_name: i, default_vector_field_name: vectors[i],
default_string_field_name: str(i), json_field_name: {'a': {"b": i}}} for i in
range(default_nb)]
self.insert(client, collection_name, rows)
rows = [{default_primary_key_field_name: i, default_vector_field_name: vectors[i],
default_string_field_name: str(i), json_field_name: i} for i in
range(default_nb, default_nb+10)]
self.insert(client, collection_name, rows)
rows = [{default_primary_key_field_name: i, default_vector_field_name: vectors[i],
default_string_field_name: str(i), json_field_name: {}} for i in
range(default_nb+10, default_nb+20)]
self.insert(client, collection_name, rows)
rows = [{default_primary_key_field_name: i, default_vector_field_name: vectors[i],
default_string_field_name: str(i), json_field_name: {'a': [1, 2, 3]}} for i in
range(default_nb + 20, default_nb + 30)]
self.insert(client, collection_name, rows)
rows = [{default_primary_key_field_name: i, default_vector_field_name: vectors[i],
default_string_field_name: str(i), json_field_name: {'a': [{'b': 1}, 2, 3]}} for i in
range(default_nb + 20, default_nb + 30)]
self.insert(client, collection_name, rows)
rows = [{default_primary_key_field_name: i, default_vector_field_name: vectors[i],
default_string_field_name: str(i), json_field_name: {'a': [{'b': None}, 2, 3]}} for i in
range(default_nb + 30, default_nb + 40)]
self.insert(client, collection_name, rows)
# 2. prepare index params
index_name = "json_index"
index_params = self.prepare_index_params(client)[0]
index_params.add_index(field_name=default_vector_field_name, index_type="AUTOINDEX", metric_type="COSINE")
index_params.add_index(field_name=json_field_name, index_name=index_name, index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type, "json_path": f"{json_field_name}['a']['b']"})
index_params.add_index(field_name=json_field_name, index_name=index_name + '1',
index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a']"})
index_params.add_index(field_name=json_field_name, index_name=index_name + '2',
index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}"})
index_params.add_index(field_name=json_field_name, index_name=index_name + '3',
index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a'][0]['b']"})
index_params.add_index(field_name=json_field_name, index_name=index_name + '4',
index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a'][0]"})
# 3. create index
self.create_index(client, collection_name, index_params)
self.describe_index(client, collection_name, index_name,
check_task=CheckTasks.check_describe_index_property,
check_items={
"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a']['b']",
"index_type": supported_varchar_scalar_index,
"field_name": json_field_name,
"index_name": index_name})
self.describe_index(client, collection_name, index_name + '1',
check_task=CheckTasks.check_describe_index_property,
check_items={
"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a']",
"index_type": supported_varchar_scalar_index,
"field_name": json_field_name,
"index_name": index_name + '1'})
self.describe_index(client, collection_name, index_name +'2',
check_task=CheckTasks.check_describe_index_property,
check_items={
"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}",
"index_type": supported_varchar_scalar_index,
"field_name": json_field_name,
"index_name": index_name + '2'})
self.describe_index(client, collection_name, index_name + '3',
check_task=CheckTasks.check_describe_index_property,
check_items={
"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a'][0]['b']",
"index_type": supported_varchar_scalar_index,
"field_name": json_field_name,
"index_name": index_name + '3'})
self.describe_index(client, collection_name, index_name + '4',
check_task=CheckTasks.check_describe_index_property,
check_items={
"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a'][0]",
"index_type": supported_varchar_scalar_index,
"field_name": json_field_name,
"index_name": index_name + '4'})
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("enable_dynamic_field", [True, False])
def test_milvus_client_insert_after_json_path_index(self, enable_dynamic_field, supported_json_cast_type,
supported_varchar_scalar_index):
"""
target: test insert after create json path index
method: create json path index after insert
steps: 1. create schema
2. create all the index parameters including json path index
3. create collection with schema and index params
4. insert
5. check the index
expected: insert successfully after create json path index
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
# 1. create collection with schema and all the index parameters
json_field_name = "my_json"
schema = self.create_schema(client, enable_dynamic_field=enable_dynamic_field)[0]
schema.add_field(default_primary_key_field_name, DataType.INT64, is_primary=True, auto_id=False)
schema.add_field(default_vector_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
schema.add_field(default_string_field_name, DataType.VARCHAR, max_length=64)
if not enable_dynamic_field:
schema.add_field(json_field_name, DataType.JSON)
index_name = "json_index"
index_params = self.prepare_index_params(client)[0]
index_params.add_index(default_vector_field_name, metric_type="COSINE")
index_params.add_index(field_name=json_field_name, index_name=index_name, index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type, "json_path": f"{json_field_name}['a']['b']"})
index_params.add_index(field_name=json_field_name, index_name=index_name + '1',
index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a']"})
index_params.add_index(field_name=json_field_name, index_name=index_name + '2',
index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}"})
index_params.add_index(field_name=json_field_name, index_name=index_name + '3',
index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a'][0]['b']"})
index_params.add_index(field_name=json_field_name, index_name=index_name + '4',
index_type=supported_varchar_scalar_index,
params={"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a'][0]"})
self.create_collection(client, collection_name, schema=schema, index_params=index_params)
# 2. insert with different data distribution
vectors = cf.gen_vectors(default_nb+50, default_dim)
rows = [{default_primary_key_field_name: i, default_vector_field_name: vectors[i],
default_string_field_name: str(i), json_field_name: {'a': {"b": i}}} for i in
range(default_nb)]
self.insert(client, collection_name, rows)
rows = [{default_primary_key_field_name: i, default_vector_field_name: vectors[i],
default_string_field_name: str(i), json_field_name: i} for i in
range(default_nb, default_nb+10)]
self.insert(client, collection_name, rows)
rows = [{default_primary_key_field_name: i, default_vector_field_name: vectors[i],
default_string_field_name: str(i), json_field_name: {}} for i in
range(default_nb+10, default_nb+20)]
self.insert(client, collection_name, rows)
rows = [{default_primary_key_field_name: i, default_vector_field_name: vectors[i],
default_string_field_name: str(i), json_field_name: {'a': [1, 2, 3]}} for i in
range(default_nb + 20, default_nb + 30)]
self.insert(client, collection_name, rows)
rows = [{default_primary_key_field_name: i, default_vector_field_name: vectors[i],
default_string_field_name: str(i), json_field_name: {'a': [{'b': 1}, 2, 3]}} for i in
range(default_nb + 20, default_nb + 30)]
self.insert(client, collection_name, rows)
rows = [{default_primary_key_field_name: i, default_vector_field_name: vectors[i],
default_string_field_name: str(i), json_field_name: {'a': [{'b': None}, 2, 3]}} for i in
range(default_nb + 30, default_nb + 40)]
self.insert(client, collection_name, rows)
# 3. check the json path index
self.describe_index(client, collection_name, index_name,
check_task=CheckTasks.check_describe_index_property,
check_items={
"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a']['b']",
"index_type": supported_varchar_scalar_index,
"field_name": json_field_name,
"index_name": index_name})
self.describe_index(client, collection_name, index_name + '1',
check_task=CheckTasks.check_describe_index_property,
check_items={
"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a']",
"index_type": supported_varchar_scalar_index,
"field_name": json_field_name,
"index_name": index_name + '1'})
self.describe_index(client, collection_name, index_name +'2',
check_task=CheckTasks.check_describe_index_property,
check_items={
"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}",
"index_type": supported_varchar_scalar_index,
"field_name": json_field_name,
"index_name": index_name + '2'})
self.describe_index(client, collection_name, index_name + '3',
check_task=CheckTasks.check_describe_index_property,
check_items={
"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a'][0]['b']",
"index_type": supported_varchar_scalar_index,
"field_name": json_field_name,
"index_name": index_name + '3'})
self.describe_index(client, collection_name, index_name + '4',
check_task=CheckTasks.check_describe_index_property,
check_items={
"json_cast_type": supported_json_cast_type,
"json_path": f"{json_field_name}['a'][0]",
"index_type": supported_varchar_scalar_index,
"field_name": json_field_name,
"index_name": index_name + '4'})
""" Test case of partial update interface """
@pytest.fixture(scope="function", params=[False, True])
def auto_id(self, request):
yield request.param
@pytest.fixture(scope="function", params=["COSINE", "L2"])
def metric_type(self, request):
yield request.param
"""
******************************************************************
# The following are invalid base cases
******************************************************************
"""
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_partial_update_new_pk_with_missing_field(self):
"""
target: Test PU will return error when provided new pk and partial field
method:
1. Create a collection
2. partial upsert a new pk with only partial field
expected: Step 2 should result fail
"""
# step 1: create collection
client = self._client()
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(default_primary_key_field_name, DataType.INT64, is_primary=True, auto_id=False)
schema.add_field(default_vector_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
schema.add_field(default_int32_field_name, DataType.INT32, nullable=True)
index_params = self.prepare_index_params(client)[0]
index_params.add_index(default_primary_key_field_name, index_type="AUTOINDEX")
index_params.add_index(default_vector_field_name, index_type="AUTOINDEX")
index_params.add_index(default_int32_field_name, index_type="AUTOINDEX")
collection_name = cf.gen_collection_name_by_testcase_name(module_index=1)
self.create_collection(client, collection_name, default_dim, schema=schema,
consistency_level="Strong", index_params=index_params)
# step 2: partial upsert a new pk with only partial field
rows = cf.gen_row_data_by_schema(nb=default_nb, schema=schema,
desired_field_names=[default_primary_key_field_name, default_int32_field_name])
error = {ct.err_code: 1100, ct.err_msg:
f"fieldSchema({default_vector_field_name}) has no corresponding fieldData pass in: invalid parameter"}
self.upsert(client, collection_name, rows, partial_update=True,
check_task=CheckTasks.err_res, check_items=error)
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_partial_update_new_field_without_dynamic_field(self):
"""
target: Test PU will return error when provided new field without dynamic field
method:
1. Create a collection with dynamic field
2. partial upsert a new field
expected: Step 2 should result fail
"""
# step 1: create collection with dynamic field
client = self._client()
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(default_primary_key_field_name, DataType.INT64, is_primary=True, auto_id=False)
schema.add_field(default_vector_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
index_params = self.prepare_index_params(client)[0]
index_params.add_index(default_primary_key_field_name, index_type="AUTOINDEX")
index_params.add_index(default_vector_field_name, index_type="AUTOINDEX")
collection_name = cf.gen_collection_name_by_testcase_name(module_index=1)
self.create_collection(client, collection_name, default_dim, schema=schema,
consistency_level="Strong", index_params=index_params)
# step 2: partial upsert a new field
row = cf.gen_row_data_by_schema(nb=default_nb, schema=schema)
self.upsert(client, collection_name, row, partial_update=True)
new_row = [{default_primary_key_field_name: i, default_int32_field_name: 99} for i in range(default_nb)]
error = {ct.err_code: 1,
ct.err_msg: f"Attempt to insert an unexpected field `{default_int32_field_name}` to collection without enabling dynamic field"}
self.upsert(client, collection_name, new_row, partial_update=True, check_task=CheckTasks.err_res, check_items=error)
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_partial_update_after_release_collection(self):
"""
target: test basic function of partial update
method:
1. create collection
2. insert a full row of data using partial update
3. partial update data
4. release collection
5. partial update data
expected: step 5 should fail
"""
# Step 1: create collection
client = self._client()
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(default_primary_key_field_name, DataType.INT64, is_primary=True, auto_id=False)
schema.add_field(default_vector_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
schema.add_field(default_string_field_name, DataType.VARCHAR, max_length=64)
index_params = self.prepare_index_params(client)[0]
index_params.add_index(default_primary_key_field_name, index_type="AUTOINDEX")
index_params.add_index(default_vector_field_name, index_type="AUTOINDEX")
index_params.add_index(default_string_field_name, index_type="AUTOINDEX")
collection_name = cf.gen_collection_name_by_testcase_name(module_index=1)
self.create_collection(client, collection_name, default_dim, schema=schema,
consistency_level="Strong", index_params=index_params)
# Step 2: insert a full row of data using partial update
rows = cf.gen_row_data_by_schema(nb=default_nb, schema=schema)
self.upsert(client, collection_name, rows, partial_update=True)
# Step 3: partial update data
new_row = cf.gen_row_data_by_schema(nb=default_nb, schema=schema,
desired_field_names=[default_primary_key_field_name, default_string_field_name])
self.upsert(client, collection_name, new_row, partial_update=True)
# Step 4: release collection
self.release_collection(client, collection_name)
# Step 5: partial update data
new_row = cf.gen_row_data_by_schema(nb=default_nb, schema=schema,
desired_field_names=[default_primary_key_field_name, default_string_field_name])
error = {ct.err_code: 101,
ct.err_msg: f"failed to query: collection not loaded"}
self.upsert(client, collection_name, new_row, partial_update=True,
check_task=CheckTasks.err_res, check_items=error)
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_partial_update_same_pk_after_delete(self):
"""
target: test PU will fail when provided same pk and partial field
method:
1. Create a collection with dynamic field
2. Insert rows
3. delete the rows
4. upsert the rows with same pk and partial field
expected: step 4 should fail
"""
# Step 1: create collection
client = self._client()
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(default_primary_key_field_name, DataType.INT64, is_primary=True, auto_id=False)
schema.add_field(default_vector_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
schema.add_field(default_int32_field_name, DataType.INT32)
index_params = self.prepare_index_params(client)[0]
index_params.add_index(default_primary_key_field_name, index_type="AUTOINDEX")
index_params.add_index(default_vector_field_name, index_type="AUTOINDEX")
index_params.add_index(default_int32_field_name, index_type="AUTOINDEX")
collection_name = cf.gen_collection_name_by_testcase_name(module_index=1)
self.create_collection(client, collection_name, default_dim, schema=schema,
consistency_level="Strong", index_params=index_params)
# Step 2: insert rows
rows = cf.gen_row_data_by_schema(nb=default_nb, schema=schema)
self.upsert(client, collection_name, rows, partial_update=True)
# Step 3: delete the rows
result = self.delete(client, collection_name, filter=default_search_exp)[0]
assert result["delete_count"] == default_nb
result = self.query(client, collection_name, filter=default_search_exp,
check_task=CheckTasks.check_nothing)[0]
assert len(result) == 0
# Step 4: upsert the rows with same pk and partial field
new_rows = cf.gen_row_data_by_schema(nb=default_nb, schema=schema,
desired_field_names=[default_primary_key_field_name, default_vector_field_name])
error = {ct.err_code: 1100,
ct.err_msg: f"fieldSchema({default_int32_field_name}) has no corresponding fieldData pass in: invalid parameter"}
self.upsert(client, collection_name, new_rows, partial_update=True,
check_task=CheckTasks.err_res, check_items=error)
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_partial_update_pk_in_wrong_partition(self):
"""
target: test PU will fail when provided pk in wrong partition
method:
1. Create a collection
2. Create 2 partitions
3. Insert rows
4. upsert the rows with pk in wrong partition
expected: step 4 should fail
"""
# Step 1: create collection
client = self._client()
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(default_primary_key_field_name, DataType.INT64, is_primary=True, auto_id=False)
schema.add_field(default_vector_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
schema.add_field(default_int32_field_name, DataType.INT32)
index_params = self.prepare_index_params(client)[0]
index_params.add_index(default_primary_key_field_name, index_type="AUTOINDEX")
index_params.add_index(default_vector_field_name, index_type="AUTOINDEX")
index_params.add_index(default_int32_field_name, index_type="AUTOINDEX")
collection_name = cf.gen_collection_name_by_testcase_name(module_index=1)
self.create_collection(client, collection_name, default_dim, schema=schema,
consistency_level="Strong", index_params=index_params)
# Step 2: Create 2 partitions
num_of_partitions = 2
partition_names = []
for _ in range(num_of_partitions):
partition_name = cf.gen_unique_str("partition")
self.create_partition(client, collection_name, partition_name)
partition_names.append(partition_name)
# Step 3: Insert rows
rows = cf.gen_row_data_by_schema(nb=default_nb, schema=schema)
gap = default_nb // num_of_partitions
for i, partition in enumerate(partition_names):
self.upsert(client, collection_name, rows[i*gap:(i+1)*gap], partition_name=partition, partial_update=True)
# Step 4: upsert the rows with pk in wrong partition
new_rows = cf.gen_row_data_by_schema(nb=gap, schema=schema,
desired_field_names=[default_primary_key_field_name, default_vector_field_name])
error = {ct.err_code: 1100,
ct.err_msg: f"fieldSchema({default_int32_field_name}) has no corresponding fieldData pass in: invalid parameter"}
self.upsert(client, collection_name, new_rows, partition_name=partition_names[-1], partial_update=True,
check_task=CheckTasks.err_res, check_items=error)
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_milvus_client_partial_update_same_pk_multiple_fields(self):
"""
target: Test PU will success and query will success
method:
1. Create a collection
2. Insert rows
3. Upsert the rows with same pk and different field
expected: Step 3 should fail
"""
# step 1: create collection
client = self._client()
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(default_primary_key_field_name, DataType.INT64, is_primary=True, auto_id=False)
schema.add_field(default_vector_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
schema.add_field(default_int32_field_name, DataType.INT32, nullable=True)
index_params = self.prepare_index_params(client)[0]
index_params.add_index(default_primary_key_field_name, index_type="AUTOINDEX")
index_params.add_index(default_vector_field_name, index_type="AUTOINDEX")
index_params.add_index(default_int32_field_name, index_type="AUTOINDEX")
collection_name = cf.gen_collection_name_by_testcase_name(module_index=1)
self.create_collection(client, collection_name, default_dim, schema=schema,
consistency_level="Strong", index_params=index_params)
# step 2: Insert rows
rows = cf.gen_row_data_by_schema(nb=default_nb, schema=schema)
self.upsert(client, collection_name, rows, partial_update=True)
# step 3: Upsert the rows with same pk and different field
new_rows = []
for i in range(default_nb):
data = {}
if i % 2 == 0:
data[default_int32_field_name] = i + 1000
data[default_primary_key_field_name] = 0
else:
data[default_vector_field_name] = [random.random() for _ in range(default_dim)]
data[default_primary_key_field_name] = 0
new_rows.append(data)
error = {ct.err_code: 1,
ct.err_msg: f"The data fields length is inconsistent. previous length is {default_nb}, current length is {default_nb // 2}"}
self.upsert(client, collection_name, new_rows, partial_update=True,
check_task=CheckTasks.err_res, check_items=error)
self.drop_collection(client, collection_name)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,557 @@
"""
Optimize API Test Cases
The optimize() method is a high-level sugar API wrapping force merge compaction.
It performs: wait for indexes -> force merge compaction -> wait for compaction ->
wait for index rebuild -> refresh load (if loaded).
L3 tests require Milvus configuration changes:
dataCoord:
segment:
maxSize: 64 # MB, default is 1024
compaction:
enableAutoCompaction: false
"""
import time
import numpy as np
import pytest
from base.client_v2_base import TestMilvusClientV2Base
from common import common_func as cf
from common import common_type as ct
from common.common_type import CaseLabel, CheckTasks
from common.constants import * # noqa: F403
from pymilvus import DataType
from utils.util_log import test_log as log
from utils.util_pymilvus import * # noqa: F403
prefix = "client_optimize"
epsilon = ct.epsilon
default_nb = ct.default_nb
default_nb_medium = ct.default_nb_medium
default_nq = ct.default_nq
default_dim = ct.default_dim
default_limit = ct.default_limit
default_search_exp = "id >= 0"
exp_res = "exp_res"
default_search_field = ct.default_float_vec_field_name
default_search_params = ct.default_search_params
default_primary_key_field_name = "id"
default_vector_field_name = "vector"
default_float_field_name = ct.default_float_field_name
default_string_field_name = ct.default_string_field_name
class TestMilvusClientOptimizeInvalid(TestMilvusClientV2Base):
"""Test cases for optimize() with invalid parameters"""
"""
******************************************************************
# The following are invalid base cases
******************************************************************
"""
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("target_size", ["abc", "1XB", "MB100", "1.2.3GB", "--1GB"])
def test_optimize_invalid_target_size_format(self, target_size):
"""
target: test optimize with invalid target_size string format
method: create collection, call optimize with malformed target_size
expected: Raise ParamError from client-side parse_target_size
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
self.create_collection(client, collection_name, default_dim)
error = {ct.err_code: 1, ct.err_msg: "Invalid"}
self.optimize(
client,
collection_name,
target_size=target_size,
check_task=CheckTasks.err_res,
check_items=error,
)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("target_size", ["0MB", "0GB", "0B", "100B", "500KB"])
def test_optimize_target_size_too_small(self, target_size):
"""
target: test optimize with target_size that resolves to less than 1MB
method: create collection, call optimize with tiny target_size
expected: Raise ParamError (target size too small)
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
self.create_collection(client, collection_name, default_dim)
error = {ct.err_code: 1, ct.err_msg: "target size too small"}
self.optimize(
client,
collection_name,
target_size=target_size,
check_task=CheckTasks.err_res,
check_items=error,
)
@pytest.mark.tags(CaseLabel.L1)
def test_optimize_nonexistent_collection(self):
"""
target: test optimize on a non-existent collection
method: call optimize on a collection that doesn't exist
expected: Raise exception (collection not found)
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
error = {ct.err_code: 0, ct.err_msg: "can't find collection"}
self.optimize(
client,
collection_name,
target_size="1GB",
check_task=CheckTasks.err_res,
check_items=error,
)
@pytest.mark.tags(CaseLabel.L1)
def test_optimize_empty_collection_name(self):
"""
target: test optimize with empty collection_name
method: call optimize with empty string
expected: Raise exception
"""
client = self._client()
error = {ct.err_code: 1, ct.err_msg: "collection_name"}
self.optimize(
client,
"",
target_size="1GB",
check_task=CheckTasks.err_res,
check_items=error,
)
@pytest.mark.tags(CaseLabel.L1)
def test_optimize_invalid_collection_name(self):
"""
target: test optimize with invalid collection_name (blank space)
method: call optimize with whitespace collection_name
expected: Raise exception (invalid collection name)
"""
client = self._client()
error = {ct.err_code: 1100, ct.err_msg: "Invalid collection name"}
self.optimize(
client,
" ",
target_size="1GB",
check_task=CheckTasks.err_res,
check_items=error,
)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("target_size", [[], {}, (1, 2)])
def test_optimize_invalid_target_size_type(self, target_size):
"""
target: test optimize with invalid target_size type
method: call optimize with non-string/non-numeric target_size
expected: Raise ParamError
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
self.create_collection(client, collection_name, default_dim)
error = {ct.err_code: 1, ct.err_msg: "target_size must be a string or number"}
self.optimize(
client,
collection_name,
target_size=target_size,
check_task=CheckTasks.err_res,
check_items=error,
)
class TestMilvusClientOptimizeValid(TestMilvusClientV2Base):
"""Test cases for optimize() with valid parameters"""
"""
******************************************************************
# The following are valid base cases
******************************************************************
"""
@pytest.mark.tags(CaseLabel.L1)
def test_optimize_empty_collection(self):
"""
target: test optimize on an empty collection
method: create collection, call optimize with wait=True
expected: Returns OptimizeResult with status="success"
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
self.create_collection(client, collection_name, default_dim)
result = self.optimize(client, collection_name, target_size="1GB")[0]
assert result.status == "success"
assert result.collection_name == collection_name
# Empty collection may return compaction_id=-1 (no segments to compact)
assert isinstance(result.compaction_id, int)
log.info(f"Optimize on empty collection completed: {result}")
@pytest.mark.tags(CaseLabel.L3)
def test_optimize_default_target_size(self):
"""
target: test optimize with default target_size (None)
method: create collection, insert data, flush, optimize without target_size
expected: Compaction completes successfully with auto-calculated size
note: L3 - requires config change (segment.maxSize=64MB)
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
self.create_collection(client, collection_name, dim)
rng = np.random.default_rng(seed=19530)
rows = [
{
default_primary_key_field_name: i,
default_vector_field_name: list(rng.random((1, dim))[0]),
}
for i in range(default_nb)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
result = self.optimize(client, collection_name, timeout=300)[0]
assert result.status == "success"
assert result.collection_name == collection_name
assert result.compaction_id > 0
log.info(f"Optimize with default target_size completed: {result}")
@pytest.mark.tags(CaseLabel.L3)
@pytest.mark.parametrize("target_size", ["512MB", "1GB", "2GB"])
def test_optimize_explicit_target_size(self, target_size):
"""
target: test optimize with various explicit target_size strings
method: create collection, insert data, flush, optimize with target_size
expected: Compaction completes successfully
note: L3 - requires config change (segment.maxSize=64MB)
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
self.create_collection(client, collection_name, dim)
rng = np.random.default_rng(seed=19530)
rows = [
{
default_primary_key_field_name: i,
default_vector_field_name: list(rng.random((1, dim))[0]),
}
for i in range(default_nb)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
result = self.optimize(client, collection_name, target_size=target_size, timeout=300)[0]
assert result.status == "success"
assert result.collection_name == collection_name
assert result.target_size == target_size
log.info(f"Optimize with target_size={target_size} completed: {result}")
@pytest.mark.tags(CaseLabel.L3)
def test_optimize_result_fields(self):
"""
target: test optimize result contains all expected fields
method: create collection, insert data, flush, optimize, verify result fields
expected: OptimizeResult has status, collection_name, compaction_id, target_size, progress
note: L3 - requires config change (segment.maxSize=64MB)
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
self.create_collection(client, collection_name, dim)
rng = np.random.default_rng(seed=19530)
rows = [
{
default_primary_key_field_name: i,
default_vector_field_name: list(rng.random((1, dim))[0]),
}
for i in range(default_nb)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
target_size = "1GB"
result = self.optimize(client, collection_name, target_size=target_size, timeout=300)[0]
# Verify all result fields
assert result.status == "success"
assert result.collection_name == collection_name
assert isinstance(result.compaction_id, int)
assert result.compaction_id > 0
assert result.target_size == target_size
assert isinstance(result.progress, list)
assert len(result.progress) > 0
log.info(f"Optimize result fields verified: progress={result.progress}")
@pytest.mark.tags(CaseLabel.L3)
def test_optimize_with_multiple_segments(self):
"""
target: test optimize merges multiple segments
method: create collection, insert in batches with flush to create segments, optimize
expected: Compaction completes, segment count reduced
note: L3 - requires config change (segment.maxSize=64MB)
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
self.create_collection(client, collection_name, dim)
rng = np.random.default_rng(seed=19530)
num_batches = 5
batch_size = default_nb
for batch in range(num_batches):
rows = [
{
default_primary_key_field_name: batch * batch_size + i,
default_vector_field_name: list(rng.random((1, dim))[0]),
}
for i in range(batch_size)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
log.info(f"Inserted batch {batch + 1}/{num_batches}")
result = self.optimize(client, collection_name, target_size="2GB", timeout=600)[0]
assert result.status == "success"
log.info(f"Optimize with {num_batches} batches completed: compaction_id={result.compaction_id}")
@pytest.mark.tags(CaseLabel.L3)
def test_optimize_search_after(self):
"""
target: test search works correctly after optimize
method: create collection, insert data, flush, optimize, search
expected: Search returns correct results
note: L3 - requires config change (segment.maxSize=64MB)
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
nb = default_nb
self.create_collection(client, collection_name, dim)
rng = np.random.default_rng(seed=19530)
rows = [
{
default_primary_key_field_name: i,
default_vector_field_name: list(rng.random((1, dim))[0]),
}
for i in range(nb)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
# Optimize (includes refresh_load if loaded)
result = self.optimize(client, collection_name, target_size="2GB", timeout=300)[0]
assert result.status == "success"
# Search after optimize
search_vectors = rng.random((1, dim))
search_res = self.search(
client,
collection_name,
list(search_vectors),
limit=10,
output_fields=[default_primary_key_field_name],
)[0]
log.info(f"Search results: {search_res}")
assert len(search_res) == 1
assert len(search_res[0]) == 10
log.info(f"Search after optimize returned {len(search_res[0])} results")
@pytest.mark.tags(CaseLabel.L3)
def test_optimize_wait_false_task_tracking(self):
"""
target: test optimize with wait=False returns OptimizeTask with progress tracking
method: create collection, insert data, flush, optimize with wait=False, track progress
expected: Task completes, progress stages are recorded
note: L3 - requires config change (segment.maxSize=64MB)
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
self.create_collection(client, collection_name, dim)
rng = np.random.default_rng(seed=19530)
rows = [
{
default_primary_key_field_name: i,
default_vector_field_name: list(rng.random((1, dim))[0]),
}
for i in range(default_nb)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
# Call optimize with wait=False to get OptimizeTask
task = self.optimize(client, collection_name, target_size="1GB", wait=False)[0]
# Track progress
cost = 300
start = time.time()
while not task.done():
progress = task.progress()
log.info(f"Optimize progress: {progress}")
time.sleep(2)
if time.time() - start > cost:
raise Exception(f"Optimize task cost more than {cost}s")
# Get result
result = task.result(timeout=10)
assert result.status == "success"
assert result.collection_name == collection_name
# Verify progress history
history = task.progress_history()
assert len(history) > 0
log.info(f"Optimize task completed with progress history: {history}")
@pytest.mark.tags(CaseLabel.L3)
def test_optimize_wait_false_cancel(self):
"""
target: test cancelling an optimize task
method: create collection, insert data, flush, start optimize with wait=False, cancel it
expected: Task is cancelled, result raises MilvusException
note: L3 - requires config change (segment.maxSize=64MB)
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
self.create_collection(client, collection_name, dim)
rng = np.random.default_rng(seed=19530)
num_batches = 5
for batch in range(num_batches):
rows = [
{
default_primary_key_field_name: batch * 1000 + i,
default_vector_field_name: list(rng.random((1, dim))[0]),
}
for i in range(1000)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
# Start optimize with wait=False
task = self.optimize(client, collection_name, target_size="2GB", wait=False)[0]
# Wait a moment then cancel
time.sleep(2)
cancelled = task.cancel()
log.info(f"Task cancel result: {cancelled}")
assert task.cancelled()
log.info("Optimize task cancelled successfully")
@pytest.mark.tags(CaseLabel.L3)
def test_optimize_verify_segment_count(self):
"""
target: test optimize reduces segment count
method: create collection, insert in batches, check segments before/after optimize
expected: Fewer segments after optimize
note: L3 - requires config change (segment.maxSize=64MB)
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
self.create_collection(client, collection_name, dim)
rng = np.random.default_rng(seed=19530)
num_batches = 5
batch_size = default_nb
for batch in range(num_batches):
rows = [
{
default_primary_key_field_name: batch * batch_size + i,
default_vector_field_name: list(rng.random((1, dim))[0]),
}
for i in range(batch_size)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
# Get stable segment count before optimize
assert self.wait_for_index_ready(client, collection_name, default_vector_field_name, timeout=300)
self.release_collection(client, collection_name)
self.load_collection(client, collection_name)
segments_before = client.list_loaded_segments(collection_name)
segment_count_before = len(segments_before)
log.info(f"Segment count before optimize: {segment_count_before}")
# Optimize (handles compaction + index rebuild + refresh load)
result = self.optimize(client, collection_name, target_size="2GB", timeout=600)[0]
assert result.status == "success"
# Release and reload to get updated segment info
assert self.wait_for_index_ready(client, collection_name, default_vector_field_name, timeout=300)
self.release_collection(client, collection_name)
self.load_collection(client, collection_name)
segments_after = client.list_loaded_segments(collection_name)
segment_count_after = len(segments_after)
log.info(f"Segment count after optimize: {segment_count_after}")
assert segment_count_after <= segment_count_before, (
f"Expected fewer segments after optimize, got {segment_count_after} >= {segment_count_before}"
)
log.info(f"Optimize reduced segments from {segment_count_before} to {segment_count_after}")
@pytest.mark.tags(CaseLabel.L3)
def test_optimize_numeric_target_size(self):
"""
target: test optimize with numeric target_size (bytes)
method: create collection, insert data, flush, optimize with int target_size
expected: Compaction completes, parse_target_size treats number as bytes
note: L3 - requires config change (segment.maxSize=64MB)
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
self.create_collection(client, collection_name, dim)
rng = np.random.default_rng(seed=19530)
rows = [
{
default_primary_key_field_name: i,
default_vector_field_name: list(rng.random((1, dim))[0]),
}
for i in range(default_nb)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
# 1GB in bytes
target_size_bytes = 1024 * 1024 * 1024
result = self.optimize(client, collection_name, target_size=target_size_bytes, timeout=300)[0]
assert result.status == "success"
log.info(f"Optimize with numeric target_size={target_size_bytes} completed")
@pytest.mark.tags(CaseLabel.L3)
def test_optimize_with_clustering_key(self):
"""
target: test optimize on collection with clustering key
method: create collection with clustering key, insert data, optimize
expected: Optimize completes successfully
note: L3 - requires config change (segment.maxSize=64MB)
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
dim = 128
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(
default_primary_key_field_name,
DataType.INT64,
is_primary=True,
auto_id=False,
)
schema.add_field(default_vector_field_name, DataType.FLOAT_VECTOR, dim=dim)
schema.add_field(
default_string_field_name,
DataType.VARCHAR,
max_length=64,
is_clustering_key=True,
)
index_params = self.prepare_index_params(client)[0]
index_params.add_index(default_vector_field_name, metric_type="COSINE")
self.create_collection(
client,
collection_name,
dimension=dim,
schema=schema,
index_params=index_params,
)
rng = np.random.default_rng(seed=19530)
rows = [
{
default_primary_key_field_name: i,
default_vector_field_name: list(rng.random((1, dim))[0]),
default_string_field_name: f"str_{i}",
}
for i in range(default_nb)
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
result = self.optimize(client, collection_name, target_size="2GB", timeout=300)[0]
assert result.status == "success"
log.info(f"Optimize with clustering key completed: {result}")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,897 @@
import pytest
from base.client_v2_base import TestMilvusClientV2Base
from common import common_func as cf
from common import common_type as ct
from common.common_type import CaseLabel, CheckTasks
from utils.util_pymilvus import DataType, gen_vectors
default_dim = ct.default_dim
class TestPartitionKeyParams(TestMilvusClientV2Base):
"""Test case of partition key params"""
@pytest.mark.tags(CaseLabel.L0)
@pytest.mark.parametrize("par_key_field", [ct.default_int64_field_name, ct.default_string_field_name])
def test_partition_key_on_field_schema(self, par_key_field):
"""
Method
1. create a collection with partition key on
2. verify insert, build, load and search successfully
3. drop collection
"""
client = self._client()
schema = self.create_schema(client, auto_id=True)[0]
self.add_field(schema, "pk", DataType.INT64, is_primary=True)
self.add_field(
schema,
ct.default_int64_field_name,
DataType.INT64,
is_partition_key=(par_key_field == ct.default_int64_field_name),
)
self.add_field(
schema,
ct.default_string_field_name,
DataType.VARCHAR,
max_length=ct.default_length,
is_partition_key=(par_key_field == ct.default_string_field_name),
)
self.add_field(schema, ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
c_name = cf.gen_collection_name_by_testcase_name()
self.create_collection(client, c_name, schema=schema)
partitions = self.list_partitions(client, c_name)[0]
assert len(partitions) == ct.default_partition_num
# insert
nb = 1000
string_prefix = cf.gen_str_by_length(length=6)
entities_per_parkey = 10
for _ in range(entities_per_parkey):
float_vec_values = gen_vectors(nb, ct.default_dim)
data = [
{
ct.default_int64_field_name: i,
ct.default_string_field_name: string_prefix + str(i),
ct.default_float_vec_field_name: float_vec_values[i],
}
for i in range(nb)
]
self.insert(client, c_name, data)
# flush
self.flush(client, c_name)
# build index
index_params = self.prepare_index_params(client)[0]
index_params.add_index(
field_name=ct.default_float_vec_field_name,
index_type="IVF_SQ8",
metric_type="COSINE",
params={"nlist": 128},
)
self.create_index(client, c_name, index_params)
# load
self.load_collection(client, c_name)
# search
nq = 10
search_vectors = gen_vectors(nq, ct.default_dim)
# search with mixed filtered
res1 = self.search(
client,
c_name,
data=search_vectors,
anns_field=ct.default_float_vec_field_name,
search_params=ct.default_search_params,
limit=entities_per_parkey,
filter=f'{ct.default_int64_field_name} in [1,3,5] && {ct.default_string_field_name} in ["{string_prefix}1","{string_prefix}3","{string_prefix}5"]',
output_fields=[ct.default_int64_field_name, ct.default_string_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": nq, "limit": entities_per_parkey},
)[0]
# search with partition key filter only or with non partition key
res2 = self.search(
client,
c_name,
data=search_vectors,
anns_field=ct.default_float_vec_field_name,
search_params=ct.default_search_params,
limit=entities_per_parkey,
filter=f"{ct.default_int64_field_name} in [1,3,5]",
output_fields=[ct.default_int64_field_name, ct.default_string_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": nq, "limit": entities_per_parkey},
)[0]
# search with partition key filter only or with non partition key
res3 = self.search(
client,
c_name,
data=search_vectors,
anns_field=ct.default_float_vec_field_name,
search_params=ct.default_search_params,
limit=entities_per_parkey,
filter=f'{ct.default_string_field_name} in ["{string_prefix}1","{string_prefix}3","{string_prefix}5"]',
output_fields=[ct.default_int64_field_name, ct.default_string_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": nq, "limit": entities_per_parkey},
)[0]
# assert the results persist
for i in range(nq):
assert res1[i].ids == res2[i].ids == res3[i].ids
# search with 'or' to verify no partition key optimization local with or binary expr
query_res1 = self.query(
client,
c_name,
filter=f'{ct.default_string_field_name} == "{string_prefix}5" || {ct.default_int64_field_name} in [2,4,6]',
output_fields=["count(*)"],
)[0]
query_res2 = self.query(
client,
c_name,
filter=f'{ct.default_string_field_name} in ["{string_prefix}2","{string_prefix}4", "{string_prefix}6"] || {ct.default_int64_field_name}==5',
output_fields=["count(*)"],
)[0]
query_res3 = self.query(
client,
c_name,
filter=f'{ct.default_int64_field_name}==5 or {ct.default_string_field_name} in ["{string_prefix}2","{string_prefix}4", "{string_prefix}6"]',
output_fields=["count(*)"],
)[0]
query_res4 = self.query(
client,
c_name,
filter=f'{ct.default_int64_field_name} in [2,4,6] || {ct.default_string_field_name} == "{string_prefix}5"',
output_fields=["count(*)"],
)[0]
# assert the results persist
assert (
query_res1[0].get("count(*)")
== query_res2[0].get("count(*)")
== query_res3[0].get("count(*)")
== query_res4[0].get("count(*)")
== 40
)
@pytest.mark.tags(CaseLabel.L0)
@pytest.mark.parametrize("par_key_field", [ct.default_int64_field_name, ct.default_string_field_name])
@pytest.mark.parametrize("index_on_par_key_field", [True, False])
def test_partition_key_on_collection_schema(self, par_key_field, index_on_par_key_field):
"""
Method
1. create a collection with partition key on collection schema with customized num_partitions
2. verify insert, build, load and search successfully
3. drop collection
"""
client = self._client()
schema = self.create_schema(client, auto_id=False, partition_key_field=par_key_field)[0]
self.add_field(schema, "pk", DataType.VARCHAR, max_length=ct.default_length, is_primary=True)
self.add_field(schema, ct.default_int64_field_name, DataType.INT64)
self.add_field(schema, ct.default_string_field_name, DataType.VARCHAR, max_length=ct.default_length)
self.add_field(schema, ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
c_name = cf.gen_collection_name_by_testcase_name()
self.create_collection(client, c_name, schema=schema, num_partitions=9)
# verify partition num is 9 as specified, not default 16
partitions = self.list_partitions(client, c_name)[0]
assert len(partitions) == 9
# insert
nb = 1000
string_prefix = cf.gen_str_by_length(length=6)
entities_per_parkey = 20
for n in range(entities_per_parkey):
float_vec_values = gen_vectors(nb, ct.default_dim)
data = [
{
"pk": str(n * nb + i),
ct.default_int64_field_name: i,
ct.default_string_field_name: string_prefix + str(i),
ct.default_float_vec_field_name: float_vec_values[i],
}
for i in range(nb)
]
self.insert(client, c_name, data)
# flush
self.flush(client, c_name)
# build index
index_params = self.prepare_index_params(client)[0]
index_params.add_index(
field_name=ct.default_float_vec_field_name, index_type="FLAT", metric_type="COSINE", params={}
)
if index_on_par_key_field:
index_params.add_index(field_name=par_key_field)
self.create_index(client, c_name, index_params)
# load
self.load_collection(client, c_name)
# search
nq = 10
search_vectors = gen_vectors(nq, ct.default_dim)
# search with mixed filtered
self.search(
client,
c_name,
data=search_vectors,
anns_field=ct.default_float_vec_field_name,
search_params=ct.default_search_params,
limit=entities_per_parkey,
filter=f'{ct.default_int64_field_name} in [1,3,5] && {ct.default_string_field_name} in ["{string_prefix}1","{string_prefix}3","{string_prefix}5"]',
output_fields=[ct.default_int64_field_name, ct.default_string_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": nq, "limit": entities_per_parkey},
)
@pytest.mark.tags(CaseLabel.L2)
def test_partition_key_off_in_field_but_enable_in_schema(self):
"""
Method:
1. create a collection with partition key off in field but enabled in schema via partition_key_field
2. verify the collection created successfully and partition key is enabled
"""
client = self._client()
schema = self.create_schema(client, auto_id=True, partition_key_field=ct.default_int64_field_name)[0]
self.add_field(schema, "pk", DataType.INT64, is_primary=True)
self.add_field(schema, ct.default_int64_field_name, DataType.INT64, is_partition_key=False)
self.add_field(schema, ct.default_string_field_name, DataType.VARCHAR, max_length=ct.default_length)
self.add_field(schema, ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
c_name = cf.gen_collection_name_by_testcase_name()
self.create_collection(client, c_name, schema=schema, num_partitions=10)
partitions = self.list_partitions(client, c_name)[0]
assert len(partitions) == 10
# verify partition key is enabled via describe_collection
desc = self.describe_collection(client, c_name)[0]
par_key_fields = [f for f in desc["fields"] if f.get("is_partition_key")]
assert len(par_key_fields) == 1
assert par_key_fields[0]["name"] == ct.default_int64_field_name
class TestPartitionKeyInvalidParams(TestMilvusClientV2Base):
"""Test case of partition key invalid params"""
@pytest.mark.tags(CaseLabel.L2)
def test_max_partitions(self):
"""
Method
1. create a collection with max partitions
2. insert
3. drop collection
4. create a collection with max partitions + 1
5. verify the error raised
"""
max_partition = ct.max_partition_num
client = self._client()
schema = self.create_schema(client, auto_id=True)[0]
self.add_field(schema, "pk", DataType.INT64, is_primary=True)
self.add_field(schema, ct.default_int64_field_name, DataType.INT64)
self.add_field(
schema, ct.default_string_field_name, DataType.VARCHAR, max_length=ct.default_length, is_partition_key=True
)
self.add_field(schema, ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
c_name = cf.gen_collection_name_by_testcase_name()
self.create_collection(client, c_name, schema=schema, num_partitions=max_partition)
partitions = self.list_partitions(client, c_name)[0]
assert len(partitions) == max_partition
# insert
nb = 100
string_prefix = cf.gen_str_by_length(length=6)
for _ in range(5):
float_vec_values = gen_vectors(nb, ct.default_dim)
data = [
{
ct.default_int64_field_name: i,
ct.default_string_field_name: string_prefix + str(i),
ct.default_float_vec_field_name: float_vec_values[i],
}
for i in range(nb)
]
self.insert(client, c_name, data)
# drop collection
self.drop_collection(client, c_name)
# create a collection with max partitions + 1
num_partitions = max_partition + 1
err_msg = f"partition number ({num_partitions}) exceeds max configuration ({max_partition})"
c_name = cf.gen_collection_name_by_testcase_name()
self.create_collection(
client,
c_name,
schema=schema,
num_partitions=num_partitions,
check_task=CheckTasks.err_res,
check_items={"err_code": 1100, "err_msg": err_msg},
)
@pytest.mark.tags(CaseLabel.L1)
def test_min_partitions(self):
"""
Method
1. create a collection with min partitions
2. insert
3. drop collection
4. create a collection with min partitions - 1
5. verify the error raised
"""
min_partition = 1
client = self._client()
schema = self.create_schema(client, auto_id=False)[0]
self.add_field(schema, "pk", DataType.VARCHAR, max_length=ct.default_length, is_primary=True)
self.add_field(schema, ct.default_int64_field_name, DataType.INT64, is_partition_key=True)
self.add_field(schema, ct.default_string_field_name, DataType.VARCHAR, max_length=ct.default_length)
self.add_field(schema, ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
c_name = cf.gen_collection_name_by_testcase_name()
self.create_collection(client, c_name, schema=schema, num_partitions=min_partition)
partitions = self.list_partitions(client, c_name)[0]
assert len(partitions) == min_partition
# insert
nb = 100
string_prefix = cf.gen_str_by_length(length=6)
for n in range(5):
float_vec_values = gen_vectors(nb, ct.default_dim)
data = [
{
"pk": str(n * nb + i),
ct.default_int64_field_name: i,
ct.default_string_field_name: string_prefix + str(i),
ct.default_float_vec_field_name: float_vec_values[i],
}
for i in range(nb)
]
self.insert(client, c_name, data)
self.flush(client, c_name)
# drop collection
self.drop_collection(client, c_name)
# create a collection with min partitions - 1
err_msg = "The specified num_partitions should be greater than or equal to 1"
c_name = cf.gen_collection_name_by_testcase_name()
self.create_collection(
client,
c_name,
schema=schema,
num_partitions=min_partition - 1,
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
self.create_collection(
client,
c_name,
schema=schema,
num_partitions=min_partition - 3,
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
@pytest.mark.tags(CaseLabel.L0)
@pytest.mark.parametrize("is_par_key", [None, "", "invalid", 0.1, [], {}, ()])
def test_invalid_partition_key_values(self, is_par_key):
"""
Method
1. create a schema and add field with invalid is_partition_key values
2. verify the error raised
"""
client = self._client()
schema = self.create_schema(client, auto_id=True)[0]
err_msg = "Param is_partition_key must be bool type"
self.add_field(
schema,
ct.default_int64_field_name,
DataType.INT64,
is_partition_key=is_par_key,
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("num_partitions", [True, False, "", "invalid", 0.1, [], {}, ()])
def test_invalid_partitions_values(self, num_partitions):
"""
Method
1. create a collection with invalid num_partitions
2. verify the error raised
"""
client = self._client()
schema = self.create_schema(client, auto_id=True)[0]
self.add_field(schema, "pk", DataType.INT64, is_primary=True)
self.add_field(schema, ct.default_int64_field_name, DataType.INT64, is_partition_key=True)
self.add_field(schema, ct.default_string_field_name, DataType.VARCHAR, max_length=ct.default_length)
self.add_field(schema, ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
err_msg = "invalid num_partitions type"
c_name = cf.gen_collection_name_by_testcase_name()
self.create_collection(
client,
c_name,
schema=schema,
num_partitions=num_partitions,
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
@pytest.mark.tags(CaseLabel.L0)
def test_partition_key_on_multi_fields(self):
"""
Method
1. create a collection with partition key on multi fields
2. verify the error raised
"""
client = self._client()
# sub-case 1: both defined in field schema via add_field
# pymilvus(>=3.1.0rc61) validates partition key uniqueness client-side at add_field
schema = self.create_schema(client, auto_id=True)[0]
self.add_field(schema, "pk", DataType.INT64, is_primary=True)
self.add_field(schema, ct.default_int64_field_name, DataType.INT64, is_partition_key=True)
err_msg = "Expected only one partition key field"
self.add_field(
schema,
ct.default_string_field_name,
DataType.VARCHAR,
max_length=ct.default_length,
is_partition_key=True,
check_task=CheckTasks.err_res,
check_items={"err_code": 1, "err_msg": err_msg},
)
# sub-case 2: partition_key_field passed as list in create_schema
err_msg = "Param partition_key_field must be str type"
self.create_schema(
client,
auto_id=True,
partition_key_field=[ct.default_int64_field_name, ct.default_string_field_name],
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
# sub-case 3: one defined in field schema, one defined in create_schema
# pymilvus raises client-side when adding the field named by partition_key_field
schema = self.create_schema(client, auto_id=True, partition_key_field=ct.default_string_field_name)[0]
self.add_field(schema, "pk", DataType.INT64, is_primary=True)
self.add_field(schema, ct.default_int64_field_name, DataType.INT64, is_partition_key=True)
err_msg = "Expected only one partition key field"
self.add_field(
schema,
ct.default_string_field_name,
DataType.VARCHAR,
max_length=ct.default_length,
check_task=CheckTasks.err_res,
check_items={"err_code": 1, "err_msg": err_msg},
)
@pytest.mark.tags(CaseLabel.L0)
@pytest.mark.parametrize("is_int64_primary", [True, False])
def test_partition_key_on_primary_key(self, is_int64_primary):
"""
Method
1. create a collection with partition key on primary key
2. verify the error raised
"""
client = self._client()
# sub-case 1: partition key set on primary field via add_field
schema = self.create_schema(client, auto_id=False)[0]
if is_int64_primary:
self.add_field(schema, "pk", DataType.INT64, is_primary=True, is_partition_key=True)
else:
self.add_field(
schema, "pk", DataType.VARCHAR, max_length=ct.default_length, is_primary=True, is_partition_key=True
)
self.add_field(schema, ct.default_int64_field_name, DataType.INT64)
self.add_field(schema, ct.default_string_field_name, DataType.VARCHAR, max_length=ct.default_length)
self.add_field(schema, ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
err_msg = "the partition key field must not be primary field"
c_name = cf.gen_collection_name_by_testcase_name()
self.create_collection(
client,
c_name,
schema=schema,
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
# sub-case 2: partition key set on primary field via create_schema partition_key_field
schema = self.create_schema(client, auto_id=False, partition_key_field="pk")[0]
if is_int64_primary:
self.add_field(schema, "pk", DataType.INT64, is_primary=True)
else:
self.add_field(schema, "pk", DataType.VARCHAR, max_length=ct.default_length, is_primary=True)
self.add_field(schema, ct.default_int64_field_name, DataType.INT64)
self.add_field(schema, ct.default_string_field_name, DataType.VARCHAR, max_length=ct.default_length)
self.add_field(schema, ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
err_msg = "the partition key field must not be primary field"
c_name = cf.gen_collection_name_by_testcase_name()
self.create_collection(
client,
c_name,
schema=schema,
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
@pytest.mark.tags(CaseLabel.L1)
def test_partition_key_on_and_off(self):
"""
Method
1. create a schema with one field partition key on via add_field and another via create_schema
2. verify the error raised
"""
client = self._client()
# sub-case 1: int64 field is_partition_key=True, schema partition_key_field=vector field
# pymilvus raises client-side when adding the field named by partition_key_field
schema = self.create_schema(client, auto_id=True, partition_key_field=ct.default_float_vec_field_name)[0]
self.add_field(schema, "pk", DataType.INT64, is_primary=True)
self.add_field(schema, ct.default_int64_field_name, DataType.INT64, is_partition_key=True)
self.add_field(schema, ct.default_string_field_name, DataType.VARCHAR, max_length=ct.default_length)
err_msg = "Expected only one partition key field"
self.add_field(
schema,
ct.default_float_vec_field_name,
DataType.FLOAT_VECTOR,
dim=default_dim,
check_task=CheckTasks.err_res,
check_items={"err_code": 1, "err_msg": err_msg},
)
# sub-case 2: string1 is_partition_key=True, schema partition_key_field=string2
# pymilvus raises client-side when adding the field named by partition_key_field
schema = self.create_schema(client, auto_id=True, partition_key_field="string2")[0]
self.add_field(schema, "pk", DataType.INT64, is_primary=True)
self.add_field(schema, "string1", DataType.VARCHAR, max_length=ct.default_length, is_partition_key=True)
err_msg = "Expected only one partition key field"
self.add_field(
schema,
"string2",
DataType.VARCHAR,
max_length=ct.default_length,
check_task=CheckTasks.err_res,
check_items={"err_code": 1, "err_msg": err_msg},
)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize(
"field_type",
[
DataType.FLOAT_VECTOR,
DataType.BINARY_VECTOR,
DataType.FLOAT,
DataType.DOUBLE,
DataType.BOOL,
DataType.INT8,
DataType.INT16,
DataType.INT32,
DataType.JSON,
],
)
def test_partition_key_on_invalid_type_fields(self, field_type):
"""
Method
1. create a collection with partition key on invalid type fields
2. verify the error raised
"""
client = self._client()
schema = self.create_schema(client, auto_id=True)[0]
self.add_field(schema, "pk", DataType.INT64, is_primary=True)
self.add_field(schema, "int8", DataType.INT8, is_partition_key=(field_type == DataType.INT8))
self.add_field(schema, "int16", DataType.INT16, is_partition_key=(field_type == DataType.INT16))
self.add_field(schema, "int32", DataType.INT32, is_partition_key=(field_type == DataType.INT32))
self.add_field(schema, "bool", DataType.BOOL, is_partition_key=(field_type == DataType.BOOL))
self.add_field(schema, "float", DataType.FLOAT, is_partition_key=(field_type == DataType.FLOAT))
self.add_field(schema, "double", DataType.DOUBLE, is_partition_key=(field_type == DataType.DOUBLE))
self.add_field(schema, "json_field", DataType.JSON, is_partition_key=(field_type == DataType.JSON))
self.add_field(schema, ct.default_int64_field_name, DataType.INT64)
self.add_field(schema, ct.default_string_field_name, DataType.VARCHAR, max_length=ct.default_length)
if field_type == DataType.BINARY_VECTOR:
self.add_field(
schema, ct.default_binary_vec_field_name, DataType.BINARY_VECTOR, dim=default_dim, is_partition_key=True
)
else:
self.add_field(
schema,
ct.default_float_vec_field_name,
DataType.FLOAT_VECTOR,
dim=default_dim,
is_partition_key=(field_type == DataType.FLOAT_VECTOR),
)
err_msg = "Partition key field type must be DataType.INT64 or DataType.VARCHAR"
c_name = cf.gen_collection_name_by_testcase_name()
self.create_collection(
client,
c_name,
schema=schema,
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
@pytest.mark.tags(CaseLabel.L2)
def test_partition_key_on_not_existed_fields(self):
"""
Method
1. create a collection with partition key on not existed fields
2. verify the error raised
"""
client = self._client()
schema = self.create_schema(client, auto_id=True, partition_key_field="non_existing_field")[0]
self.add_field(schema, "pk", DataType.INT64, is_primary=True)
self.add_field(schema, ct.default_int64_field_name, DataType.INT64)
self.add_field(schema, ct.default_string_field_name, DataType.VARCHAR, max_length=ct.default_length)
self.add_field(schema, ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
err_msg = "the specified partition key field {non_existing_field} not exist"
c_name = cf.gen_collection_name_by_testcase_name()
self.create_collection(
client,
c_name,
schema=schema,
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
@pytest.mark.tags(CaseLabel.L1)
def test_partition_key_on_empty_and_num_partitions_set(self):
"""
Method
1. create a collection with partition key on empty and num_partitions set
2. verify the error raised
"""
client = self._client()
# sub-case 1: partition_key_field="" → SDK error
schema = self.create_schema(client, auto_id=True, partition_key_field="")[0]
self.add_field(schema, "pk", DataType.INT64, is_primary=True)
self.add_field(schema, ct.default_int64_field_name, DataType.INT64)
self.add_field(schema, ct.default_string_field_name, DataType.VARCHAR, max_length=ct.default_length)
self.add_field(schema, ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
err_msg = "the specified partition key field {} not exist"
c_name = cf.gen_collection_name_by_testcase_name()
self.create_collection(
client,
c_name,
schema=schema,
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
# sub-case 2: no partition key but num_partitions set → server error
schema = self.create_schema(client, auto_id=True)[0]
self.add_field(schema, "pk", DataType.INT64, is_primary=True)
self.add_field(schema, ct.default_int64_field_name, DataType.INT64)
self.add_field(schema, ct.default_string_field_name, DataType.VARCHAR, max_length=ct.default_length)
self.add_field(schema, ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
err_msg = "num_partitions should only be specified with partition key field enabled"
c_name = cf.gen_collection_name_by_testcase_name()
self.create_collection(
client,
c_name,
schema=schema,
num_partitions=200,
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
class TestPartitionKeyInsertInvalid(TestMilvusClientV2Base):
"""Test case of partition key insert invalid data"""
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("invalid_data", [99, True, None, [], {}, ()])
def test_partition_key_insert_invalid_data(self, invalid_data):
"""
Method:
1. create a collection with partition key on varchar field
2. insert entities with invalid partition key value
3. verify the error raised
"""
client = self._client()
schema = self.create_schema(client, auto_id=False, partition_key_field=ct.default_string_field_name)[0]
self.add_field(schema, "pk", DataType.VARCHAR, max_length=ct.default_length, is_primary=True)
self.add_field(schema, ct.default_int64_field_name, DataType.INT64)
self.add_field(schema, ct.default_string_field_name, DataType.VARCHAR, max_length=ct.default_length)
self.add_field(schema, ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
c_name = cf.gen_collection_name_by_testcase_name()
self.create_collection(client, c_name, schema=schema)
# insert with invalid partition key value at index 1
nb = 10
string_prefix = cf.gen_str_by_length(length=6)
float_vec_values = gen_vectors(nb, ct.default_dim)
data = [
{
"pk": str(i),
ct.default_int64_field_name: i,
ct.default_string_field_name: string_prefix + str(i),
ct.default_float_vec_field_name: float_vec_values[i],
}
for i in range(nb)
]
data[1][ct.default_string_field_name] = invalid_data # inject invalid data
if invalid_data is None:
# None is skipped in row-based insert, causing row count mismatch
err_msg = "the num_rows"
err_code = 1100
else:
# non-string types trigger DataNotMatchException in row-based insert
err_msg = "The Input data type is inconsistent with defined schema"
err_code = 1
self.insert(
client, c_name, data, check_task=CheckTasks.err_res, check_items={"err_code": err_code, "err_msg": err_msg}
)
class TestPartitionApiForbidden(TestMilvusClientV2Base):
"""Test case of partition api forbidden when partition key is on"""
@pytest.mark.tags(CaseLabel.L1)
def test_create_partition(self):
"""
Method:
1. return error if create partition when partition key is on
2. return error if insert with partition_name when partition key is on
3. return error if drop partition when partition key is on
4. return success if list/has partition when partition key is on
5. return error if load partition when partition key is on
6. return error if release partition when partition key is on
7. return error if search/query/delete with partition_names when partition key is on
Expected: raise exception for partition-specific operations
"""
client = self._client()
schema = self.create_schema(client, auto_id=True)[0]
self.add_field(schema, "pk", DataType.INT64, is_primary=True)
self.add_field(schema, ct.default_int64_field_name, DataType.INT64)
self.add_field(
schema, ct.default_string_field_name, DataType.VARCHAR, max_length=ct.default_length, is_partition_key=True
)
self.add_field(schema, ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
c_name = cf.gen_collection_name_by_testcase_name()
self.create_collection(client, c_name, schema=schema)
# create partition → error
err_msg = "disable create partition if partition key mode is used"
partition_name = cf.gen_unique_str("partition")
self.create_partition(
client,
c_name,
partition_name,
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
# list/has partition → allowed
partitions = self.list_partitions(client, c_name)[0]
assert len(partitions) > 0
assert self.has_partition(client, c_name, partitions[0])[0]
# insert without partition_name → allowed
nb = 100
string_prefix = cf.gen_str_by_length(length=6)
entities_per_parkey = 10
for _ in range(entities_per_parkey):
float_vec_values = gen_vectors(nb, ct.default_dim)
data = [
{
ct.default_int64_field_name: i,
ct.default_string_field_name: string_prefix + str(i),
ct.default_float_vec_field_name: float_vec_values[i],
}
for i in range(nb)
]
self.insert(client, c_name, data)
# insert with partition_name → error
err_msg = "not support manually specifying the partition names if partition key mode is used"
self.insert(
client,
c_name,
data,
partition_name=partitions[0],
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
# load partitions → error
err_msg = "disable load partitions if partition key mode is used"
self.load_partitions(
client,
c_name,
[partitions[0]],
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
# flush + index + load collection → allowed
self.flush(client, c_name)
index_params = self.prepare_index_params(client)[0]
index_params.add_index(
field_name=ct.default_float_vec_field_name,
index_type="IVF_SQ8",
metric_type="COSINE",
params={"nlist": 128},
)
self.create_index(client, c_name, index_params)
self.load_collection(client, c_name)
# search without partition_names → allowed
nq = 10
search_vectors = gen_vectors(nq, ct.default_dim)
res1 = self.search(
client,
c_name,
data=search_vectors,
anns_field=ct.default_float_vec_field_name,
search_params=ct.default_search_params,
limit=entities_per_parkey,
filter=f'{ct.default_int64_field_name} in [1,3,5] && {ct.default_string_field_name} in ["{string_prefix}1","{string_prefix}3","{string_prefix}5"]',
output_fields=[ct.default_int64_field_name, ct.default_string_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": nq, "limit": ct.default_limit},
)[0]
pks = res1[0].ids[:3]
# search with partition_names → error
err_msg = "not support manually specifying the partition names if partition key mode is used"
self.search(
client,
c_name,
data=search_vectors,
anns_field=ct.default_float_vec_field_name,
search_params=ct.default_search_params,
limit=entities_per_parkey,
filter=f"{ct.default_int64_field_name} in [1,3,5]",
output_fields=[ct.default_int64_field_name, ct.default_string_field_name],
partition_names=[partitions[0]],
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
# get_load_state with partition → allowed (v1: loading_progress/wait_for_loading_complete)
load_state = self.get_load_state(client, c_name, partition_name=partitions[0])[0]
assert "state" in load_state
# get_partition_stats → allowed
stats = self.get_partition_stats(client, c_name, partitions[0])[0]
assert "row_count" in stats
# flush → allowed (v1: partition_w.flush())
self.flush(client, c_name)
# delete with partition_name → error
err_msg = "not support manually specifying the partition names if partition key mode is used"
self.delete(
client,
c_name,
filter=f"pk in {pks}",
partition_name=partitions[0],
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
# query with partition_names → error
self.query(
client,
c_name,
filter=f"pk in {pks}",
partition_names=[partitions[0]],
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
# release partitions → error
err_msg = "disable release partitions if partition key mode is used"
self.release_partitions(
client,
c_name,
[partitions[0]],
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
# drop partition → error
err_msg = "disable drop partition if partition key mode is used"
self.drop_partition(
client,
c_name,
partitions[0],
check_task=CheckTasks.err_res,
check_items={"err_code": 2, "err_msg": err_msg},
)
@@ -0,0 +1,532 @@
import random
import time
import pandas as pd
import pytest
from base.client_v2_base import TestMilvusClientV2Base
from common import common_func as cf
from common import common_type as ct
from common.common_type import CaseLabel, CheckTasks
from pymilvus import AnnSearchRequest, DataType, RRFRanker
from utils.util_log import test_log as log
@pytest.mark.tags(CaseLabel.L1)
class TestPartitionKeyIsolation(TestMilvusClientV2Base):
"""Test case of partition key isolation"""
def test_par_key_isolation_with_valid_expr(self):
# create
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
dim = 128
partition_key = "scalar_6"
enable_isolation = "true"
self.drop_collection(client, collection_name)
schema = self.create_schema(client, enable_dynamic_field=True)[0]
self.add_field(schema, "id", DataType.INT64, is_primary=True)
self.add_field(
schema, "scalar_3", DataType.VARCHAR, max_length=1000, is_partition_key=bool(partition_key == "scalar_3")
)
self.add_field(
schema, "scalar_6", DataType.VARCHAR, max_length=1000, is_partition_key=bool(partition_key == "scalar_6")
)
self.add_field(
schema, "scalar_9", DataType.VARCHAR, max_length=1000, is_partition_key=bool(partition_key == "scalar_9")
)
self.add_field(
schema, "scalar_12", DataType.VARCHAR, max_length=1000, is_partition_key=bool(partition_key == "scalar_12")
)
self.add_field(
schema,
"scalar_5_linear",
DataType.VARCHAR,
max_length=1000,
is_partition_key=bool(partition_key == "scalar_5_linear"),
)
self.add_field(schema, "emb", DataType.FLOAT_VECTOR, dim=dim)
self.create_collection(client, collection_name, schema=schema, num_partitions=1)
self.alter_collection_properties(client, collection_name, {"partitionkey.isolation": enable_isolation})
log.info(f"collection {collection_name} created")
batch_size = 500
data_size = 1000
epoch = data_size // batch_size
remainder = data_size % batch_size
all_data = []
for i in range(epoch + 1):
if i == epoch:
if remainder == 0:
break
batch_size = remainder
start_idx = i * batch_size
end_idx = (i + 1) * batch_size
t0 = time.time()
data = []
for j in range(start_idx, end_idx):
data.append(
{
"id": j,
"scalar_3": str(j % 3),
"scalar_6": str(j % 6),
"scalar_9": str(j % 9),
"scalar_12": str(j % 12),
"scalar_5_linear": str(j % 5),
"emb": [random.random() for _ in range(dim)],
}
)
# collect data as DataFrame for pandas verification later
df_data = {
"id": [j for j in range(start_idx, end_idx)],
"scalar_3": [str(j % 3) for j in range(start_idx, end_idx)],
"scalar_6": [str(j % 6) for j in range(start_idx, end_idx)],
"scalar_9": [str(j % 9) for j in range(start_idx, end_idx)],
"scalar_12": [str(j % 12) for j in range(start_idx, end_idx)],
"scalar_5_linear": [str(j % 5) for j in range(start_idx, end_idx)],
}
all_data.append(pd.DataFrame(df_data))
log.info(f"generate test data {len(data)} cost time {time.time() - t0}")
self.insert(client, collection_name, data)
self.flush(client, collection_name)
log.info(f"collection {collection_name} insert done")
all_df = pd.concat(all_data)
compact_id = self.compact(client, collection_name)[0]
self.wait_for_compaction_ready(client, compact_id)
t0 = time.time()
index_params = self.prepare_index_params(client)[0]
index_params.add_index(
field_name="emb", metric_type="L2", index_type="HNSW", params={"M": 16, "efConstruction": 64}
)
self.create_index(client, collection_name, index_params, timeout=360)
index_list = self.list_indexes(client, collection_name)[0]
for index_name in index_list:
self.wait_for_index_ready(client, collection_name, index_name)
tt = time.time() - t0
log.info(f"create index cost time {tt}")
compact_id = self.compact(client, collection_name)[0]
self.wait_for_compaction_ready(client, compact_id)
t0 = time.time()
self.load_collection(client, collection_name)
log.info(f"load collection cost time {time.time() - t0}")
valid_expressions = [
"scalar_6 == '1' and scalar_12 == '1'",
"scalar_6 == '1' and scalar_12 > '1'",
"scalar_6 == '3' and (scalar_12 == '1' or scalar_3 != '1')",
"scalar_6 == '2' and ('4' < scalar_12 < '6' or scalar_3 == '1')",
"scalar_6 == '5' and scalar_12 in ['1', '3', '5']",
"scalar_6 == '1'",
]
for expr in valid_expressions:
res = self.search(
client,
collection_name,
data=[[random.random() for _ in range(dim)]],
anns_field="emb",
filter=expr,
search_params={"metric_type": "L2", "params": {}},
limit=1000,
output_fields=["scalar_3", "scalar_6", "scalar_12"],
consistency_level="Strong",
)[0]
true_res = all_df.query(expr)
assert len(res[0]) == len(true_res)
def test_par_key_isolation_with_unsupported_expr(self):
# create
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
partition_key = "scalar_6"
enable_isolation = "true"
self.drop_collection(client, collection_name)
schema = self.create_schema(client, enable_dynamic_field=True)[0]
self.add_field(schema, "id", DataType.INT64, is_primary=True)
self.add_field(
schema, "scalar_3", DataType.VARCHAR, max_length=1000, is_partition_key=bool(partition_key == "scalar_3")
)
self.add_field(
schema, "scalar_6", DataType.VARCHAR, max_length=1000, is_partition_key=bool(partition_key == "scalar_6")
)
self.add_field(
schema, "scalar_9", DataType.VARCHAR, max_length=1000, is_partition_key=bool(partition_key == "scalar_9")
)
self.add_field(
schema, "scalar_12", DataType.VARCHAR, max_length=1000, is_partition_key=bool(partition_key == "scalar_12")
)
self.add_field(
schema,
"scalar_5_linear",
DataType.VARCHAR,
max_length=1000,
is_partition_key=bool(partition_key == "scalar_5_linear"),
)
self.add_field(schema, "emb", DataType.FLOAT_VECTOR, dim=128)
self.create_collection(client, collection_name, schema=schema, num_partitions=1)
self.alter_collection_properties(client, collection_name, {"partitionkey.isolation": enable_isolation})
log.info(f"collection {collection_name} created")
batch_size = 500
data_size = 1000
epoch = data_size // batch_size
remainder = data_size % batch_size
for i in range(epoch + 1):
if i == epoch:
if remainder == 0:
break
batch_size = remainder
start_idx = i * batch_size
end_idx = (i + 1) * batch_size
t0 = time.time()
data = []
for j in range(start_idx, end_idx):
data.append(
{
"id": j,
"scalar_3": str(j % 3),
"scalar_6": str(j % 6),
"scalar_9": str(j % 9),
"scalar_12": str(j % 12),
"scalar_5_linear": str(j % 5),
"emb": [random.random() for _ in range(128)],
}
)
log.info(f"generate test data {len(data)} cost time {time.time() - t0}")
self.insert(client, collection_name, data)
compact_id = self.compact(client, collection_name)[0]
self.wait_for_compaction_ready(client, compact_id)
t0 = time.time()
index_params = self.prepare_index_params(client)[0]
index_params.add_index(
field_name="emb", metric_type="L2", index_type="HNSW", params={"M": 16, "efConstruction": 64}
)
self.create_index(client, collection_name, index_params, timeout=360)
index_list = self.list_indexes(client, collection_name)[0]
for index_name in index_list:
self.wait_for_index_ready(client, collection_name, index_name)
tt = time.time() - t0
log.info(f"create index cost time {tt}")
compact_id = self.compact(client, collection_name)[0]
self.wait_for_compaction_ready(client, compact_id)
t0 = time.time()
self.load_collection(client, collection_name)
log.info(f"load collection cost time {time.time() - t0}")
self.flush(client, collection_name)
log.info(f"collection {collection_name} loaded")
invalid_expressions = [
"scalar_6 in ['1', '2']",
"scalar_6 not in ['1', '2']",
"scalar_6 == '1' or scalar_3 == '1'",
"scalar_6 != '1'",
"scalar_6 > '1'",
"'1' < scalar_6 < '3'",
"scalar_3 == '1'", # scalar_3 is not partition key
]
false_result = []
for expr in invalid_expressions:
# v2 api_request catches exceptions and returns (Error, False) instead of raising
# use check_task=check_nothing to skip default assert_succ in ResponseChecker
res, check = self.search(
client,
collection_name,
data=[[random.random() for _ in range(128)]],
anns_field="emb",
filter=expr,
search_params={"metric_type": "L2", "params": {"nprobe": 16}},
limit=10,
output_fields=["scalar_6"],
consistency_level="Strong",
check_task=CheckTasks.check_nothing,
)
if check is not False:
log.info(f"search with {expr} get res {res}")
false_result.append(expr)
else:
log.info(f"search with unsupported expr {expr} get {res}")
if len(false_result) > 0:
log.info(f"search with unsupported expr {false_result}, but not raise error\n")
assert False
def test_hybrid_search_par_key_isolation_with_unsupported_expr(self):
"""
target: verify hybrid search enforces partition key isolation like search
method: create partition key isolation collection, compare search and hybrid_search invalid filters
expected: hybrid_search rejects multi-tenant and no-tenant filters
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
self.drop_collection(client, collection_name)
dim = 5
query_a = [[0.10, 0.20, 0.30, 0.40, 0.50]]
query_b = [[0.50, 0.40, 0.30, 0.20, 0.10]]
schema = self.create_schema(client, enable_dynamic_field=False)[0]
self.add_field(schema, "id", DataType.INT64, is_primary=True)
self.add_field(schema, "vector_a", DataType.FLOAT_VECTOR, dim=dim)
self.add_field(schema, "vector_b", DataType.FLOAT_VECTOR, dim=dim)
self.add_field(schema, "tenant", DataType.VARCHAR, max_length=64, is_partition_key=True)
self.add_field(schema, "color", DataType.VARCHAR, max_length=64)
index_params = self.prepare_index_params(client)[0]
for field in ["vector_a", "vector_b"]:
index_params.add_index(field_name=field, index_name=field, index_type="AUTOINDEX", metric_type="COSINE")
self.create_collection(
client,
collection_name,
schema=schema,
index_params=index_params,
num_partitions=16,
properties={"partitionkey.isolation": "true"},
)
rows = [
{
"id": 1,
"vector_a": [0.10, 0.20, 0.30, 0.40, 0.50],
"vector_b": [0.50, 0.40, 0.30, 0.20, 0.10],
"tenant": "tenant_a",
"color": "tenant_a_1",
},
{
"id": 2,
"vector_a": [0.11, 0.21, 0.31, 0.41, 0.51],
"vector_b": [0.51, 0.41, 0.31, 0.21, 0.11],
"tenant": "tenant_a",
"color": "tenant_a_2",
},
{
"id": 3,
"vector_a": [0.90, 0.80, 0.70, 0.60, 0.50],
"vector_b": [0.50, 0.60, 0.70, 0.80, 0.90],
"tenant": "tenant_b",
"color": "tenant_b_1",
},
{
"id": 4,
"vector_a": [0.91, 0.81, 0.71, 0.61, 0.51],
"vector_b": [0.51, 0.61, 0.71, 0.81, 0.91],
"tenant": "tenant_b",
"color": "tenant_b_2",
},
{
"id": 5,
"vector_a": [0.10, 0.20, 0.30, 0.40, 0.50],
"vector_b": [0.50, 0.40, 0.30, 0.20, 0.10],
"tenant": "tenant_c",
"color": "tenant_c_control",
},
]
self.insert(client, collection_name, rows)
self.flush(client, collection_name)
self.load_collection(client, collection_name)
res = self.search(
client,
collection_name,
data=query_a,
anns_field="vector_a",
filter='tenant == "tenant_a"',
limit=5,
output_fields=["id", "tenant", "color"],
search_params={"metric_type": "COSINE", "params": {}},
)[0]
assert {hit["tenant"] for hit in res[0]} == {"tenant_a"}
invalid_filters = [
('tenant in ["tenant_a", "tenant_b"]', "partition key isolation does not support IN"),
("", "partition key not found in expr"),
]
for expr, err_msg in invalid_filters:
res, check = self.search(
client,
collection_name,
data=query_a,
anns_field="vector_a",
filter=expr,
limit=5,
output_fields=["id", "tenant", "color"],
search_params={"metric_type": "COSINE", "params": {}},
check_task=CheckTasks.check_nothing,
)
assert check is False
assert err_msg in str(res)
for expr, err_msg in invalid_filters:
reqs = [
AnnSearchRequest(
data=query_a,
anns_field="vector_a",
param={"metric_type": "COSINE", "params": {}},
limit=5,
filter=expr,
),
AnnSearchRequest(
data=query_b,
anns_field="vector_b",
param={"metric_type": "COSINE", "params": {}},
limit=5,
filter=expr,
),
]
res, check = self.hybrid_search(
client,
collection_name,
reqs=reqs,
ranker=RRFRanker(60),
limit=5,
output_fields=["id", "tenant", "color"],
check_task=CheckTasks.check_nothing,
)
if check is not False:
log.info(f"hybrid_search with unsupported expr {expr} got res {res}")
pytest.xfail(f"issue: https://github.com/milvus-io/milvus/issues/50398, expr: {expr}")
assert err_msg in str(res)
def test_par_key_isolation_without_partition_key(self):
# create
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
partition_key = "None"
enable_isolation = "true"
self.drop_collection(client, collection_name)
schema = self.create_schema(client, enable_dynamic_field=True)[0]
self.add_field(schema, "id", DataType.INT64, is_primary=True)
self.add_field(
schema, "scalar_3", DataType.VARCHAR, max_length=1000, is_partition_key=bool(partition_key == "scalar_3")
)
self.add_field(
schema, "scalar_6", DataType.VARCHAR, max_length=1000, is_partition_key=bool(partition_key == "scalar_6")
)
self.add_field(
schema, "scalar_9", DataType.VARCHAR, max_length=1000, is_partition_key=bool(partition_key == "scalar_9")
)
self.add_field(
schema, "scalar_12", DataType.VARCHAR, max_length=1000, is_partition_key=bool(partition_key == "scalar_12")
)
self.add_field(
schema,
"scalar_5_linear",
DataType.VARCHAR,
max_length=1000,
is_partition_key=bool(partition_key == "scalar_5_linear"),
)
self.add_field(schema, "emb", DataType.FLOAT_VECTOR, dim=128)
self.create_collection(client, collection_name, schema=schema)
err_msg = "partition key isolation mode is enabled but no partition key field is set"
self.alter_collection_properties(
client,
collection_name,
{"partitionkey.isolation": enable_isolation},
check_task=CheckTasks.err_res,
check_items={"err_code": 1100, "err_msg": err_msg},
)
def test_set_par_key_isolation_after_vector_indexed(self):
# create
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
partition_key = "scalar_6"
enable_isolation = "false"
self.drop_collection(client, collection_name)
schema = self.create_schema(client, enable_dynamic_field=True)[0]
self.add_field(schema, "id", DataType.INT64, is_primary=True)
self.add_field(
schema, "scalar_3", DataType.VARCHAR, max_length=1000, is_partition_key=bool(partition_key == "scalar_3")
)
self.add_field(
schema, "scalar_6", DataType.VARCHAR, max_length=1000, is_partition_key=bool(partition_key == "scalar_6")
)
self.add_field(
schema, "scalar_9", DataType.VARCHAR, max_length=1000, is_partition_key=bool(partition_key == "scalar_9")
)
self.add_field(
schema, "scalar_12", DataType.VARCHAR, max_length=1000, is_partition_key=bool(partition_key == "scalar_12")
)
self.add_field(
schema,
"scalar_5_linear",
DataType.VARCHAR,
max_length=1000,
is_partition_key=bool(partition_key == "scalar_5_linear"),
)
self.add_field(schema, "emb", DataType.FLOAT_VECTOR, dim=128)
self.create_collection(client, collection_name, schema=schema, num_partitions=1)
self.alter_collection_properties(client, collection_name, {"partitionkey.isolation": enable_isolation})
log.info(f"collection {collection_name} created")
batch_size = 500
data_size = 1000
epoch = data_size // batch_size
remainder = data_size % batch_size
for i in range(epoch + 1):
if i == epoch:
if remainder == 0:
break
batch_size = remainder
start_idx = i * batch_size
end_idx = (i + 1) * batch_size
t0 = time.time()
data = []
for j in range(start_idx, end_idx):
data.append(
{
"id": j,
"scalar_3": str(j % 3),
"scalar_6": str(j % 6),
"scalar_9": str(j % 9),
"scalar_12": str(j % 12),
"scalar_5_linear": str(j % 5),
"emb": [random.random() for _ in range(128)],
}
)
log.info(f"generate test data {len(data)} cost time {time.time() - t0}")
self.insert(client, collection_name, data)
compact_id = self.compact(client, collection_name)[0]
self.wait_for_compaction_ready(client, compact_id)
t0 = time.time()
index_params = self.prepare_index_params(client)[0]
index_params.add_index(
field_name="emb", metric_type="L2", index_type="HNSW", params={"M": 16, "efConstruction": 64}
)
self.create_index(client, collection_name, index_params, timeout=360)
index_list = self.list_indexes(client, collection_name)[0]
for index_name in index_list:
self.wait_for_index_ready(client, collection_name, index_name)
tt = time.time() - t0
log.info(f"create index cost time {tt}")
# try set isolation=true after index exists → expect failure
error = {
ct.err_code: 702,
ct.err_msg: "can not alter partition key isolation mode if the collection already has a vector index",
}
self.alter_collection_properties(
client,
collection_name,
{"partitionkey.isolation": "true"},
check_task=CheckTasks.err_res,
check_items=error,
)
# drop index, then set isolation=true → expect success
for index_name in index_list:
self.drop_index(client, collection_name, index_name)
self.alter_collection_properties(client, collection_name, {"partitionkey.isolation": "true"})
# recreate index, load, search
index_params = self.prepare_index_params(client)[0]
index_params.add_index(
field_name="emb", metric_type="L2", index_type="HNSW", params={"M": 16, "efConstruction": 64}
)
self.create_index(client, collection_name, index_params, timeout=360)
self.load_collection(client, collection_name)
res = self.search(
client,
collection_name,
data=[[random.random() for _ in range(128)]],
anns_field="emb",
filter="scalar_6 == '1' and scalar_3 == '1'",
search_params={"metric_type": "L2", "params": {"nprobe": 16}},
limit=10,
output_fields=["scalar_6", "scalar_3"],
consistency_level="Strong",
)[0]
log.info(f"search res {res}")
assert len(res[0]) > 0
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,264 @@
import pytest
from pymilvus import DataType
from common.common_type import CaseLabel, CheckTasks
from common import common_type as ct
from common import common_func as cf
from base.client_v2_base import TestMilvusClientV2Base
default_dim = ct.default_dim
default_nb = 3000
default_limit = 10
default_nq = 1
PK_FIELD = "id"
VEC_FIELD = "emb"
INT64_ARRAY = "int64_array"
VARCHAR_ARRAY = "varchar_array"
FLOAT_ARRAY = "float_array"
BOOL_ARRAY = "bool_array"
ARRAY_FIELDS = [INT64_ARRAY, VARCHAR_ARRAY, FLOAT_ARRAY, BOOL_ARRAY]
CHECK_ITEMS = {"nq": default_nq, "limit": default_limit, "metric": "L2",
"enable_milvus_client_api": True, "pk_name": PK_FIELD}
def _build_array_schema(wrapper, client, nullable=False):
"""Build schema: int64 PK, float_vector(128), 4 typed array fields."""
schema = wrapper.create_schema(client)[0]
schema.add_field(PK_FIELD, DataType.INT64, is_primary=True)
schema.add_field(VEC_FIELD, DataType.FLOAT_VECTOR, dim=default_dim)
schema.add_field(INT64_ARRAY, DataType.ARRAY, element_type=DataType.INT64,
max_capacity=100, nullable=nullable)
schema.add_field(VARCHAR_ARRAY, DataType.ARRAY, element_type=DataType.VARCHAR,
max_capacity=100, max_length=128, nullable=nullable)
schema.add_field(FLOAT_ARRAY, DataType.ARRAY, element_type=DataType.FLOAT,
max_capacity=100, nullable=nullable)
schema.add_field(BOOL_ARRAY, DataType.ARRAY, element_type=DataType.BOOL,
max_capacity=100, nullable=nullable)
return schema
def _gen_deterministic_data(nb, schema, null_ratio=0.0):
"""Generate deterministic array data. int64_array = [i%50, (i+1)%50, (i+2)%50]."""
data = cf.gen_row_data_by_schema(nb=nb, schema=schema)
for i in range(nb):
data[i][PK_FIELD] = i
if null_ratio > 0 and i < int(nb * null_ratio):
for f in ARRAY_FIELDS:
data[i][f] = None
else:
data[i][INT64_ARRAY] = [i % 50, (i + 1) % 50, (i + 2) % 50]
data[i][VARCHAR_ARRAY] = [f"s_{i % 30}", f"s_{(i + 10) % 30}"]
data[i][FLOAT_ARRAY] = [float(i % 100), float((i * 3) % 100)]
data[i][BOOL_ARRAY] = [i % 2 == 0, i % 3 == 0]
return data
@pytest.mark.xdist_group("TestSearchArrayShared")
@pytest.mark.tags(CaseLabel.GPU)
class TestSearchArrayShared(TestMilvusClientV2Base):
"""Shared collection for array search tests.
Schema: int64(PK), float_vector(128), int64_array, varchar_array, float_array, bool_array
Data: 3000 rows, deterministic arrays, INVERTED index on arrays, FLAT/L2 on vector
"""
shared_alias = "TestSearchArrayShared"
def setup_class(self):
super().setup_class(self)
self.collection_name = "TestSearchArrayShared" + cf.gen_unique_str("_")
@pytest.fixture(scope="class", autouse=True)
def prepare_collection(self, request):
client = self._client(alias=self.shared_alias)
schema = _build_array_schema(self, client)
self.create_collection(client, self.collection_name, schema=schema,
force_teardown=False)
data = _gen_deterministic_data(default_nb, schema)
self.__class__.shared_data = data
self.insert(client, self.collection_name, data=data)
self.flush(client, self.collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=VEC_FIELD, metric_type="L2", index_type="FLAT")
for f in ARRAY_FIELDS:
idx.add_index(field_name=f, index_type="INVERTED")
self.create_index(client, self.collection_name, index_params=idx)
self.load_collection(client, self.collection_name)
def teardown():
self.drop_collection(self._client(alias=self.shared_alias),
self.collection_name)
request.addfinalizer(teardown)
@pytest.mark.tags(CaseLabel.L1)
def test_search_array_contains(self):
"""
target: verify array_contains filter returns only matching rows
method: search with array_contains(int64_array, 5), validate returned IDs
expected: every hit has 5 in its int64_array
"""
client = self._client(alias=self.shared_alias)
vectors = cf.gen_vectors(default_nq, default_dim)
target_val = 5
expr = f"array_contains({INT64_ARRAY}, {target_val})"
expected_ids = {i for i in range(default_nb)
if target_val in [i % 50, (i + 1) % 50, (i + 2) % 50]}
res, _ = self.search(client, self.collection_name, data=vectors,
anns_field=VEC_FIELD, limit=default_limit,
filter=expr, output_fields=[INT64_ARRAY],
check_task=CheckTasks.check_search_results,
check_items=CHECK_ITEMS)
for hit in res[0]:
assert hit.id in expected_ids
assert target_val in hit.entity[INT64_ARRAY]
@pytest.mark.tags(CaseLabel.L1)
def test_search_array_contains_all(self):
"""
target: verify array_contains_all returns rows containing all specified values
method: search with array_contains_all(int64_array, [0, 1])
expected: every hit's int64_array contains both 0 and 1
"""
client = self._client(alias=self.shared_alias)
vectors = cf.gen_vectors(default_nq, default_dim)
target_vals = [0, 1]
expr = f"array_contains_all({INT64_ARRAY}, {target_vals})"
res, _ = self.search(client, self.collection_name, data=vectors,
anns_field=VEC_FIELD, limit=default_limit,
filter=expr, output_fields=[INT64_ARRAY],
check_task=CheckTasks.check_search_results,
check_items=CHECK_ITEMS)
for hit in res[0]:
arr = hit.entity[INT64_ARRAY]
for v in target_vals:
assert v in arr, f"ID {hit.id}: {arr} missing {v}"
@pytest.mark.tags(CaseLabel.L1)
def test_search_array_contains_any(self):
"""
target: verify array_contains_any returns rows containing at least one value
method: search with array_contains_any(int64_array, [49, 48])
expected: every hit's int64_array contains 49 or 48
"""
client = self._client(alias=self.shared_alias)
vectors = cf.gen_vectors(default_nq, default_dim)
target_vals = [49, 48]
expr = f"array_contains_any({INT64_ARRAY}, {target_vals})"
res, _ = self.search(client, self.collection_name, data=vectors,
anns_field=VEC_FIELD, limit=default_limit,
filter=expr, output_fields=[INT64_ARRAY],
check_task=CheckTasks.check_search_results,
check_items=CHECK_ITEMS)
for hit in res[0]:
arr = hit.entity[INT64_ARRAY]
assert any(v in arr for v in target_vals), \
f"ID {hit.id}: {arr} has none of {target_vals}"
@pytest.mark.tags(CaseLabel.L1)
def test_search_array_length(self):
"""
target: verify array_length filter returns rows with correct array size
method: search with array_length(int64_array) == 3
expected: all returned rows have int64_array of length 3
"""
client = self._client(alias=self.shared_alias)
vectors = cf.gen_vectors(default_nq, default_dim)
expr = f"array_length({INT64_ARRAY}) == 3"
res, _ = self.search(client, self.collection_name, data=vectors,
anns_field=VEC_FIELD, limit=default_limit,
filter=expr, output_fields=[INT64_ARRAY],
check_task=CheckTasks.check_search_results,
check_items=CHECK_ITEMS)
for hit in res[0]:
assert len(hit.entity[INT64_ARRAY]) == 3
@pytest.mark.tags(CaseLabel.L1)
def test_search_array_access(self):
"""
target: verify array index access filter works correctly
method: search with int64_array[0] == 10
expected: every hit has int64_array[0] == 10, matching rows where i % 50 == 10
"""
client = self._client(alias=self.shared_alias)
vectors = cf.gen_vectors(default_nq, default_dim)
target_val = 10
expr = f"{INT64_ARRAY}[0] == {target_val}"
expected_ids = {i for i in range(default_nb) if i % 50 == target_val}
res, _ = self.search(client, self.collection_name, data=vectors,
anns_field=VEC_FIELD, limit=default_limit,
filter=expr, output_fields=[INT64_ARRAY],
check_task=CheckTasks.check_search_results,
check_items=CHECK_ITEMS)
for hit in res[0]:
assert hit.id in expected_ids
assert hit.entity[INT64_ARRAY][0] == target_val
class TestSearchArrayIndependent(TestMilvusClientV2Base):
"""Independent tests for array search edge cases.
Each test creates its own collection with specific configurations.
"""
@pytest.mark.tags(CaseLabel.L2)
def test_search_array_without_index(self):
"""
target: verify array filter works via brute-force scan without INVERTED index
method: create collection with array fields, NO inverted index, search with filter
expected: search returns correct filtered results using brute-force scan
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
schema = _build_array_schema(self, client)
self.create_collection(client, collection_name, schema=schema)
data = _gen_deterministic_data(default_nb, schema)
self.insert(client, collection_name, data=data)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=VEC_FIELD, metric_type="L2", index_type="FLAT")
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
vectors = cf.gen_vectors(default_nq, default_dim)
target_val = 5
expr = f"array_contains({INT64_ARRAY}, {target_val})"
res, _ = self.search(client, collection_name, data=vectors,
anns_field=VEC_FIELD, limit=default_limit,
filter=expr, output_fields=[INT64_ARRAY],
check_task=CheckTasks.check_search_results,
check_items=CHECK_ITEMS)
for hit in res[0]:
assert target_val in hit.entity[INT64_ARRAY]
@pytest.mark.tags(CaseLabel.L2)
def test_search_array_nullable(self):
"""
target: verify array search handles nullable array fields correctly
method: insert 3000 rows with first 20% having None arrays, search with filter
expected: only non-null rows matching the filter are returned
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
schema = _build_array_schema(self, client, nullable=True)
self.create_collection(client, collection_name, schema=schema)
null_ratio = 0.2
null_count = int(default_nb * null_ratio)
data = _gen_deterministic_data(default_nb, schema, null_ratio=null_ratio)
self.insert(client, collection_name, data=data)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=VEC_FIELD, metric_type="L2", index_type="FLAT")
for f in ARRAY_FIELDS:
idx.add_index(field_name=f, index_type="INVERTED")
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
vectors = cf.gen_vectors(default_nq, default_dim)
target_val = 5
expr = f"array_contains({INT64_ARRAY}, {target_val})"
res, _ = self.search(client, collection_name, data=vectors,
anns_field=VEC_FIELD, limit=default_limit,
filter=expr, output_fields=[INT64_ARRAY],
check_task=CheckTasks.check_search_results,
check_items=CHECK_ITEMS)
for hit in res[0]:
assert hit.id >= null_count, \
f"ID {hit.id} is in the null range [0, {null_count})"
assert hit.entity[INT64_ARRAY] is not None
assert target_val in hit.entity[INT64_ARRAY]
@@ -0,0 +1,813 @@
import math
import pytest
from base.client_v2_base import TestMilvusClientV2Base
from common import common_func as cf
from common import common_type as ct
from common.common_type import CaseLabel, CheckTasks
from pymilvus import DataType, Function, FunctionScore, FunctionType
default_nb = ct.default_nb
default_nq = ct.default_nq
default_dim = ct.default_dim
default_limit = ct.default_limit
@pytest.mark.xdist_group("TestSearchBoostRanker")
class TestSearchBoostRanker(TestMilvusClientV2Base):
"""Test search with Boost Ranker (FunctionScore) functionality"""
def setup_class(self):
super().setup_class(self)
self.collection_name = "TestSearchBoostRanker" + cf.gen_unique_str("_")
self.pk_field_name = "id"
self.vector_field_name = "vector"
self.int64_field_name = "int64_field"
self.float_field_name = "float_field"
self.varchar_field_name = "varchar_field"
self.dim = 128
self.primary_keys = []
self.datas = []
@pytest.fixture(scope="class", autouse=True)
def prepare_collection(self, request):
"""Initialize collection with schema and data before test class runs"""
client = self._client()
# Create schema
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(self.pk_field_name, DataType.INT64, is_primary=True, auto_id=False)
schema.add_field(self.vector_field_name, DataType.FLOAT_VECTOR, dim=self.dim)
schema.add_field(self.int64_field_name, DataType.INT64)
schema.add_field(self.float_field_name, DataType.FLOAT)
schema.add_field(self.varchar_field_name, DataType.VARCHAR, max_length=256)
self.create_collection(client, self.collection_name, schema=schema, force_teardown=False)
# Insert data
data = cf.gen_row_data_by_schema(nb=default_nb, schema=schema)
self.insert(client, self.collection_name, data=data)
self.datas = data
self.primary_keys = [row[self.pk_field_name] for row in data]
# Create index
index_params = self.prepare_index_params(client)[0]
index_params.add_index(field_name=self.vector_field_name, metric_type="COSINE", index_type="AUTOINDEX")
self.create_index(client, self.collection_name, index_params=index_params)
# Load collection
self.load_collection(client, self.collection_name)
def teardown():
self.drop_collection(self._client(), self.collection_name)
request.addfinalizer(teardown)
def _make_boost_function(self, name="boost1", weight="1.5", filter_expr=None):
"""Helper to create a single boost Function"""
params = {"reranker": "boost", "weight": weight}
if filter_expr is not None:
params["filter"] = filter_expr
return Function(
name=name,
function_type=FunctionType.RERANK,
input_field_names=[],
output_field_names=[],
params=params,
)
def _make_function_score(self, functions, params=None):
"""Helper to create a FunctionScore"""
if not isinstance(functions, list):
functions = [functions]
return FunctionScore(functions=functions, params=params)
def _prepare_deterministic_collection(self, client, metric_type="COSINE"):
collection_name = "TestSearchBoostRankerCorrectness" + cf.gen_unique_str("_")
dim = 2
rows = [
{
self.pk_field_name: 1,
self.vector_field_name: [1.0, 0.0],
self.int64_field_name: 1,
self.float_field_name: 0.1,
self.varchar_field_name: "odd",
},
{
self.pk_field_name: 2,
self.vector_field_name: [0.8, 0.6],
self.int64_field_name: 2,
self.float_field_name: 0.2,
self.varchar_field_name: "even",
},
{
self.pk_field_name: 3,
self.vector_field_name: [0.6, 0.8],
self.int64_field_name: 3,
self.float_field_name: 0.3,
self.varchar_field_name: "odd",
},
{
self.pk_field_name: 4,
self.vector_field_name: [0.0, 1.0],
self.int64_field_name: 4,
self.float_field_name: 0.4,
self.varchar_field_name: "even",
},
]
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(self.pk_field_name, DataType.INT64, is_primary=True, auto_id=False)
schema.add_field(self.vector_field_name, DataType.FLOAT_VECTOR, dim=dim)
schema.add_field(self.int64_field_name, DataType.INT64)
schema.add_field(self.float_field_name, DataType.FLOAT)
schema.add_field(self.varchar_field_name, DataType.VARCHAR, max_length=256)
self.create_collection(client, collection_name, schema=schema)
self.insert(client, collection_name, data=rows)
self.flush(client, collection_name)
index_params = self.prepare_index_params(client)[0]
index_params.add_index(field_name=self.vector_field_name, metric_type=metric_type, index_type="FLAT", params={})
self.create_index(client, collection_name, index_params=index_params)
self.load_collection(client, collection_name)
return collection_name, dim
def _search_boost_correctness(self, client, collection_name, ranker):
res, _ = self.search(
client,
collection_name,
[[1.0, 0.0]],
anns_field=self.vector_field_name,
search_params={},
limit=4,
ranker=ranker,
output_fields=[self.pk_field_name],
)
hits = res[0]
return [hit[self.pk_field_name] for hit in hits], [hit["distance"] for hit in hits]
def _assert_boost_scores(self, actual_ids, actual_scores, expected_scores):
expected_ids = [pk for pk, _ in sorted(expected_scores.items(), key=lambda item: (-item[1], item[0]))]
assert actual_ids == expected_ids
for pk, score in zip(actual_ids, actual_scores):
assert math.isclose(score, expected_scores[pk], rel_tol=1e-5, abs_tol=1e-5), (
f"expected score for pk {pk} to be {expected_scores[pk]}, got {score}"
)
# ==================== Positive Tests ====================
@pytest.mark.tags(CaseLabel.L0)
def test_search_boost_ranker_single(self):
"""
target: search with a single boost ranker with filter and weight
method: create FunctionScore with one boost function, pass as ranker to search
expected: search returns results successfully
"""
client = self._client()
vectors = cf.gen_vectors(default_nq, self.dim)
boost_fn = self._make_boost_function(weight="2.0", filter_expr=f"{self.int64_field_name} > 0")
fs = self._make_function_score(boost_fn)
res, _ = self.search(
client,
self.collection_name,
vectors,
anns_field=self.vector_field_name,
limit=default_limit,
ranker=fs,
check_task=CheckTasks.check_search_results,
check_items={
"enable_milvus_client_api": True,
"nq": default_nq,
"limit": default_limit,
"pk_name": self.pk_field_name,
},
)
assert len(res) == default_nq
@pytest.mark.tags(CaseLabel.L1)
def test_search_boost_ranker_no_filter(self):
"""
target: search with boost ranker without filter (weight applies to all results)
method: create boost function without filter param
expected: search returns results successfully
"""
client = self._client()
vectors = cf.gen_vectors(default_nq, self.dim)
boost_fn = self._make_boost_function(weight="1.5")
fs = self._make_function_score(boost_fn)
res, _ = self.search(
client,
self.collection_name,
vectors,
anns_field=self.vector_field_name,
limit=default_limit,
ranker=fs,
check_task=CheckTasks.check_search_results,
check_items={
"enable_milvus_client_api": True,
"nq": default_nq,
"limit": default_limit,
"pk_name": self.pk_field_name,
},
)
assert len(res) == default_nq
@pytest.mark.tags(CaseLabel.L1)
def test_search_boost_ranker_multiple_functions(self):
"""
target: search with multiple boost functions in one FunctionScore
method: create FunctionScore with two boost functions with different filters
expected: search returns results successfully
"""
client = self._client()
vectors = cf.gen_vectors(default_nq, self.dim)
boost_fn1 = self._make_boost_function(name="boost1", weight="2.0", filter_expr=f"{self.int64_field_name} > 0")
boost_fn2 = self._make_boost_function(name="boost2", weight="0.5", filter_expr=f"{self.int64_field_name} <= 0")
fs = self._make_function_score([boost_fn1, boost_fn2])
res, _ = self.search(
client,
self.collection_name,
vectors,
anns_field=self.vector_field_name,
limit=default_limit,
ranker=fs,
check_task=CheckTasks.check_search_results,
check_items={
"enable_milvus_client_api": True,
"nq": default_nq,
"limit": default_limit,
"pk_name": self.pk_field_name,
},
)
assert len(res) == default_nq
@pytest.mark.tags(CaseLabel.L1)
def test_search_boost_ranker_boost_mode_sum(self):
"""
target: search with boost_mode="sum" in FunctionScore params
method: create FunctionScore with boost_mode="sum"
expected: search returns results with sum-based scoring
"""
client = self._client()
vectors = cf.gen_vectors(default_nq, self.dim)
boost_fn = self._make_boost_function(weight="2.0", filter_expr=f"{self.int64_field_name} > 0")
fs = self._make_function_score(boost_fn, params={"boost_mode": "sum"})
res, _ = self.search(
client,
self.collection_name,
vectors,
anns_field=self.vector_field_name,
limit=default_limit,
ranker=fs,
check_task=CheckTasks.check_search_results,
check_items={
"enable_milvus_client_api": True,
"nq": default_nq,
"limit": default_limit,
"pk_name": self.pk_field_name,
},
)
assert len(res) == default_nq
@pytest.mark.tags(CaseLabel.L1)
def test_search_boost_ranker_boost_mode_multiply(self):
"""
target: search with boost_mode="multiply" (default) in FunctionScore params
method: create FunctionScore with explicit boost_mode="multiply"
expected: search returns results successfully
"""
client = self._client()
vectors = cf.gen_vectors(default_nq, self.dim)
boost_fn = self._make_boost_function(weight="1.5", filter_expr=f"{self.int64_field_name} > 0")
fs = self._make_function_score(boost_fn, params={"boost_mode": "multiply"})
res, _ = self.search(
client,
self.collection_name,
vectors,
anns_field=self.vector_field_name,
limit=default_limit,
ranker=fs,
check_task=CheckTasks.check_search_results,
check_items={
"enable_milvus_client_api": True,
"nq": default_nq,
"limit": default_limit,
"pk_name": self.pk_field_name,
},
)
assert len(res) == default_nq
@pytest.mark.tags(CaseLabel.L1)
def test_search_boost_ranker_function_mode_sum(self):
"""
target: search with multiple functions and function_mode="sum"
method: create FunctionScore with two boost functions and function_mode="sum"
expected: search returns results successfully
"""
client = self._client()
vectors = cf.gen_vectors(default_nq, self.dim)
boost_fn1 = self._make_boost_function(name="boost1", weight="1.5", filter_expr=f"{self.int64_field_name} > 0")
boost_fn2 = self._make_boost_function(name="boost2", weight="0.8", filter_expr=f"{self.float_field_name} > 0")
fs = self._make_function_score([boost_fn1, boost_fn2], params={"function_mode": "sum"})
res, _ = self.search(
client,
self.collection_name,
vectors,
anns_field=self.vector_field_name,
limit=default_limit,
ranker=fs,
check_task=CheckTasks.check_search_results,
check_items={
"enable_milvus_client_api": True,
"nq": default_nq,
"limit": default_limit,
"pk_name": self.pk_field_name,
},
)
assert len(res) == default_nq
@pytest.mark.tags(CaseLabel.L1)
def test_search_boost_ranker_with_filter_expr(self):
"""
target: combine boost ranker with a search-level filter expression
method: pass both ranker=FunctionScore and filter=expr to search
expected: search returns filtered results with boost scoring
"""
client = self._client()
vectors = cf.gen_vectors(default_nq, self.dim)
boost_fn = self._make_boost_function(weight="2.0", filter_expr=f"{self.int64_field_name} > 500")
fs = self._make_function_score(boost_fn)
search_filter = f"{self.int64_field_name} >= 0"
res, _ = self.search(
client,
self.collection_name,
vectors,
anns_field=self.vector_field_name,
limit=default_limit,
filter=search_filter,
ranker=fs,
check_task=CheckTasks.check_search_results,
check_items={
"enable_milvus_client_api": True,
"nq": default_nq,
"limit": default_limit,
"pk_name": self.pk_field_name,
},
)
assert len(res) == default_nq
@pytest.mark.tags(CaseLabel.L1)
def test_search_boost_ranker_with_output_fields(self):
"""
target: search with boost ranker and output_fields
method: pass output_fields along with ranker
expected: search returns results with the requested output fields
"""
client = self._client()
vectors = cf.gen_vectors(default_nq, self.dim)
boost_fn = self._make_boost_function(weight="1.5")
fs = self._make_function_score(boost_fn)
output_fields = [self.int64_field_name, self.float_field_name, self.varchar_field_name]
res, _ = self.search(
client,
self.collection_name,
vectors,
anns_field=self.vector_field_name,
limit=default_limit,
output_fields=output_fields,
ranker=fs,
check_task=CheckTasks.check_search_results,
check_items={
"enable_milvus_client_api": True,
"nq": default_nq,
"limit": default_limit,
"pk_name": self.pk_field_name,
},
)
assert len(res) == default_nq
# Verify output fields are present in the entity sub-dict
for hits in res:
for hit in hits:
entity = hit.get("entity", hit)
for field in output_fields:
assert field in entity, f"Expected field '{field}' in result entity"
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("limit", [1, 10, 100])
def test_search_boost_ranker_with_limit(self, limit):
"""
target: search with boost ranker and various limit values
method: pass different limit values with boost ranker
expected: search returns correct number of results
"""
client = self._client()
vectors = cf.gen_vectors(default_nq, self.dim)
boost_fn = self._make_boost_function(weight="1.5")
fs = self._make_function_score(boost_fn)
res, _ = self.search(
client,
self.collection_name,
vectors,
anns_field=self.vector_field_name,
limit=limit,
ranker=fs,
check_task=CheckTasks.check_search_results,
check_items={
"enable_milvus_client_api": True,
"nq": default_nq,
"limit": limit,
"pk_name": self.pk_field_name,
},
)
assert len(res) == default_nq
@pytest.mark.tags(CaseLabel.L1)
def test_search_boost_ranker_score_affected(self):
"""
target: verify boost ranker actually changes scores compared to search without ranker
method: search with and without boost ranker, compare scores
expected: scores should differ when boost is applied
"""
client = self._client()
vectors = cf.gen_vectors(1, self.dim)
# Search without ranker
res_no_ranker, _ = self.search(
client,
self.collection_name,
vectors,
anns_field=self.vector_field_name,
limit=default_limit,
)
# Search with a large boost weight
boost_fn = self._make_boost_function(weight="10.0")
fs = self._make_function_score(boost_fn, params={"boost_mode": "multiply"})
res_with_ranker, _ = self.search(
client,
self.collection_name,
vectors,
anns_field=self.vector_field_name,
limit=default_limit,
ranker=fs,
)
# Verify scores are different (boost should affect scores)
scores_no_ranker = [hit["distance"] for hit in res_no_ranker[0]]
scores_with_ranker = [hit["distance"] for hit in res_with_ranker[0]]
assert scores_no_ranker != scores_with_ranker, (
"Boost ranker should change scores compared to search without ranker"
)
@pytest.mark.tags(CaseLabel.L1)
def test_search_boost_ranker_multiply_correctness(self):
"""
target: verify boost_mode=multiply applies only matching filter rows and sorts by boosted score
method: use deterministic vectors and scalar filter with known COSINE base scores
expected: returned IDs and distances match base_score * boost_weight for matched rows
"""
client = self._client()
collection_name, _ = self._prepare_deterministic_collection(client)
try:
boost_fn = self._make_boost_function(weight="2.0", filter_expr=f"{self.int64_field_name} in [2, 4]")
fs = self._make_function_score(boost_fn, params={"boost_mode": "multiply"})
actual_ids, actual_scores = self._search_boost_correctness(client, collection_name, fs)
self._assert_boost_scores(
actual_ids,
actual_scores,
{
1: 1.0,
2: 1.6,
3: 0.6,
4: 0.0,
},
)
finally:
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_search_boost_ranker_sum_correctness(self):
"""
target: verify boost_mode=sum adds boost scores only to rows matching the scorer filter
method: use deterministic vectors and scalar filter with known COSINE base scores
expected: returned IDs and distances match base_score + boost_weight for matched rows
"""
client = self._client()
collection_name, _ = self._prepare_deterministic_collection(client)
try:
boost_fn = self._make_boost_function(weight="0.5", filter_expr=f"{self.int64_field_name} in [2, 4]")
fs = self._make_function_score(boost_fn, params={"boost_mode": "sum"})
actual_ids, actual_scores = self._search_boost_correctness(client, collection_name, fs)
self._assert_boost_scores(
actual_ids,
actual_scores,
{
1: 1.0,
2: 1.3,
3: 0.6,
4: 0.5,
},
)
finally:
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_search_boost_ranker_all_boost_functions_miss_keep_base_scores(self):
"""
target: verify null boost scores are skipped when all boost function filters miss
method: use two boost functions whose filters match no row in a deterministic collection
expected: every row keeps its original base score and ordering
"""
client = self._client()
collection_name, _ = self._prepare_deterministic_collection(client)
try:
boost_fn1 = self._make_boost_function(
name="boost_none_int", weight="2.0", filter_expr=f"{self.int64_field_name} > 100"
)
boost_fn2 = self._make_boost_function(
name="boost_none_string", weight="3.0", filter_expr=f"{self.varchar_field_name} == 'missing'"
)
fs = self._make_function_score([boost_fn1, boost_fn2], params={"function_mode": "sum", "boost_mode": "sum"})
actual_ids, actual_scores = self._search_boost_correctness(client, collection_name, fs)
self._assert_boost_scores(
actual_ids,
actual_scores,
{
1: 1.0,
2: 0.8,
3: 0.6,
4: 0.0,
},
)
finally:
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_search_boost_ranker_function_mode_sum_correctness(self):
"""
target: verify multiple boost functions combine with function_mode=sum before final boost
method: use overlapping deterministic filters and boost_mode=sum
expected: rows receive the sum of matching function scores and are sorted by final score
"""
client = self._client()
collection_name, _ = self._prepare_deterministic_collection(client)
try:
boost_fn1 = self._make_boost_function(
name="boost_even", weight="0.5", filter_expr=f"{self.int64_field_name} in [2, 4]"
)
boost_fn2 = self._make_boost_function(
name="boost_high", weight="0.25", filter_expr=f"{self.int64_field_name} >= 3"
)
fs = self._make_function_score([boost_fn1, boost_fn2], params={"function_mode": "sum", "boost_mode": "sum"})
actual_ids, actual_scores = self._search_boost_correctness(client, collection_name, fs)
self._assert_boost_scores(
actual_ids,
actual_scores,
{
1: 1.0,
2: 1.3,
3: 0.85,
4: 0.75,
},
)
finally:
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_search_boost_ranker_function_mode_multiply_correctness(self):
"""
target: verify multiple boost functions combine with function_mode=multiply before final boost
method: use overlapping deterministic filters and boost_mode=multiply
expected: rows matching one or more functions use the multiplied function score; unmatched rows keep base score
"""
client = self._client()
collection_name, _ = self._prepare_deterministic_collection(client)
try:
boost_fn1 = self._make_boost_function(
name="boost_even", weight="2.0", filter_expr=f"{self.int64_field_name} in [2, 4]"
)
boost_fn2 = self._make_boost_function(
name="boost_high", weight="3.0", filter_expr=f"{self.int64_field_name} >= 3"
)
fs = self._make_function_score(
[boost_fn1, boost_fn2], params={"function_mode": "multiply", "boost_mode": "multiply"}
)
actual_ids, actual_scores = self._search_boost_correctness(client, collection_name, fs)
self._assert_boost_scores(
actual_ids,
actual_scores,
{
1: 1.0,
2: 1.6,
3: 1.8,
4: 0.0,
},
)
finally:
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_search_boost_ranker_function_multiply_boost_sum_correctness(self):
"""
target: verify function_mode=multiply and boost_mode=sum produce exact boosted scores
method: use overlapping deterministic filters so one row matches both functions and one row matches none
expected: matching function scores are multiplied, then added to the base score; unmatched rows keep base score
"""
client = self._client()
collection_name, _ = self._prepare_deterministic_collection(client)
try:
boost_fn1 = self._make_boost_function(
name="boost_even", weight="2.0", filter_expr=f"{self.int64_field_name} in [2, 4]"
)
boost_fn2 = self._make_boost_function(
name="boost_high", weight="3.0", filter_expr=f"{self.int64_field_name} >= 3"
)
fs = self._make_function_score(
[boost_fn1, boost_fn2], params={"function_mode": "multiply", "boost_mode": "sum"}
)
actual_ids, actual_scores = self._search_boost_correctness(client, collection_name, fs)
self._assert_boost_scores(
actual_ids,
actual_scores,
{
1: 1.0,
2: 2.8,
3: 3.6,
4: 6.0,
},
)
finally:
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_search_boost_ranker_l2_multiply_correctness(self):
"""
target: verify boost_mode=multiply can improve a deterministic L2 result
method: multiply row 3's L2 distance by 0.25 to move it ahead of row 2
expected: returned distances match boosted L2 distances and are sorted ascending
"""
client = self._client()
collection_name, _ = self._prepare_deterministic_collection(client, metric_type="L2")
try:
boost_fn = self._make_boost_function(weight="0.25", filter_expr=f"{self.int64_field_name} == 3")
fs = self._make_function_score(boost_fn, params={"boost_mode": "multiply"})
actual_ids, actual_scores = self._search_boost_correctness(client, collection_name, fs)
assert actual_ids == [1, 3, 2, 4]
expected_scores = {
1: 0.0,
2: 0.4,
3: 0.2,
4: 2.0,
}
for pk, score in zip(actual_ids, actual_scores):
assert math.isclose(score, expected_scores[pk], rel_tol=1e-5, abs_tol=1e-5), (
f"expected score for pk {pk} to be {expected_scores[pk]}, got {score}"
)
finally:
self.drop_collection(client, collection_name)
@pytest.mark.tags(CaseLabel.L1)
def test_search_boost_ranker_l2_sum_correctness(self):
"""
target: verify boost_mode=sum applies to deterministic L2 internal scores
method: add 0.7 to row 3's internal score to move it ahead of row 2
expected: returned distances match proxy-restored scores and are sorted ascending
"""
client = self._client()
collection_name, _ = self._prepare_deterministic_collection(client, metric_type="L2")
try:
boost_fn = self._make_boost_function(weight="0.7", filter_expr=f"{self.int64_field_name} == 3")
fs = self._make_function_score(boost_fn, params={"boost_mode": "sum"})
actual_ids, actual_scores = self._search_boost_correctness(client, collection_name, fs)
assert actual_ids == [1, 3, 2, 4]
expected_scores = {
1: 0.0,
2: 0.4,
3: 0.1,
4: 2.0,
}
for pk, score in zip(actual_ids, actual_scores):
assert math.isclose(score, expected_scores[pk], rel_tol=1e-5, abs_tol=1e-5), (
f"expected score for pk {pk} to be {expected_scores[pk]}, got {score}"
)
finally:
self.drop_collection(client, collection_name)
# ==================== Negative Tests ====================
@pytest.mark.tags(CaseLabel.L2)
def test_search_boost_ranker_invalid_weight(self):
"""
target: search with invalid weight value in boost function
method: set weight="invalid_float"
expected: search raises an error
"""
client = self._client()
vectors = cf.gen_vectors(default_nq, self.dim)
boost_fn = self._make_boost_function(weight="invalid_float")
fs = self._make_function_score(boost_fn)
self.search(
client,
self.collection_name,
vectors,
anns_field=self.vector_field_name,
limit=default_limit,
ranker=fs,
check_task=CheckTasks.err_res,
check_items={"err_code": 1100, "err_msg": "invalid parameter"},
)
@pytest.mark.tags(CaseLabel.L2)
def test_search_boost_ranker_missing_weight(self):
"""
target: search with boost function missing weight param
method: create boost function without weight in params
expected: search raises an error
"""
client = self._client()
vectors = cf.gen_vectors(default_nq, self.dim)
boost_fn = Function(
name="boost_no_weight",
function_type=FunctionType.RERANK,
input_field_names=[],
output_field_names=[],
params={"reranker": "boost"},
)
fs = self._make_function_score(boost_fn)
self.search(
client,
self.collection_name,
vectors,
anns_field=self.vector_field_name,
limit=default_limit,
ranker=fs,
check_task=CheckTasks.err_res,
check_items={"err_code": 1100, "err_msg": "must set weight params"},
)
@pytest.mark.tags(CaseLabel.L2)
def test_search_boost_ranker_with_group_by(self):
"""
target: search with boost ranker combined with group_by
method: pass both ranker and group_by_field to search
expected: search raises an error (incompatible)
"""
client = self._client()
vectors = cf.gen_vectors(default_nq, self.dim)
boost_fn = self._make_boost_function(weight="1.5")
fs = self._make_function_score(boost_fn)
self.search(
client,
self.collection_name,
vectors,
anns_field=self.vector_field_name,
limit=default_limit,
ranker=fs,
group_by_field=self.int64_field_name,
check_task=CheckTasks.err_res,
check_items={"err_code": 1100, "err_msg": "segment scorer with group_by"},
)
@pytest.mark.tags(CaseLabel.L2)
def test_search_boost_ranker_invalid_filter(self):
"""
target: search with invalid filter expression in boost function
method: set filter to an invalid expression
expected: search raises an error
"""
client = self._client()
vectors = cf.gen_vectors(default_nq, self.dim)
boost_fn = self._make_boost_function(weight="1.5", filter_expr="invalid_field @@@ 123")
fs = self._make_function_score(boost_fn)
self.search(
client,
self.collection_name,
vectors,
anns_field=self.vector_field_name,
limit=default_limit,
ranker=fs,
check_task=CheckTasks.err_res,
check_items={"err_code": 1100, "err_msg": "parse expr failed"},
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,145 @@
import pytest
from pymilvus import DataType
from common.common_type import CaseLabel, CheckTasks
from common import common_type as ct
from common import common_func as cf
from base.client_v2_base import TestMilvusClientV2Base
default_nq = ct.default_nq
default_limit = ct.default_limit
default_search_exp = f"{ct.default_int64_field_name} >= 0"
default_search_field = ct.default_float_vec_field_name
default_int64_field_name = ct.default_int64_field_name
default_float_field_name = ct.default_float_field_name
default_string_field_name = ct.default_string_field_name
half_nb = ct.default_nb // 2
class TestSearchDiskannIndependent(TestMilvusClientV2Base):
"""
******************************************************************
The following cases are used to test search about diskann index
******************************************************************
"""
@pytest.mark.tags(CaseLabel.L2)
def test_search_with_delete_data(self):
"""
target: test delete after creating index
method: 1.create collection , insert data,
2.create diskann index
3.delete data, the search
expected: assert index and deleted id not in search result
"""
# 1. initialize with data
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
dim = 100
# Create schema with auto_id and dynamic field
schema = self.create_schema(client, enable_dynamic_field=True)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True, auto_id=True)
schema.add_field(ct.default_float_field_name, DataType.FLOAT)
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=dim)
self.create_collection(client, collection_name, schema=schema)
# Insert data
data = cf.gen_row_data_by_schema(nb=ct.default_nb, schema=schema)
insert_res, _ = self.insert(client, collection_name, data=data)
ids = insert_res["ids"]
self.flush(client, collection_name)
# 2. create index
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name,
index_type="DISKANN", metric_type="L2", params={})
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
# delete half of data
expr = f'{ct.default_int64_field_name} in {ids[:half_nb]}'
self.delete(client, collection_name, filter=expr)
tmp_expr = f'{ct.default_int64_field_name} in {[0]}'
self.delete(client, collection_name, filter=tmp_expr)
# search
default_search_params = {"metric_type": "L2", "params": {"search_list": 30}}
vectors = cf.gen_vectors(default_nq, dim)
output_fields = [default_int64_field_name,
default_float_field_name, default_string_field_name]
self.search(client, collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=default_search_exp,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"ids": ids[half_nb:],
"limit": default_limit,
"metric": "L2",
"pk_name": ct.default_int64_field_name,
"enable_milvus_client_api": True})
@pytest.mark.tags(CaseLabel.L1)
def test_search_with_scalar_field(self):
"""
target: test search with scalar field
method: 1.create collection , insert data
2.create more index ,then load
3.search with expr
expected: assert index and search successfully
"""
# 1. initialize with data
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
dim = 66
# Create schema with varchar PK and dynamic field
schema = self.create_schema(client, enable_dynamic_field=True)[0]
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535, is_primary=True)
schema.add_field(ct.default_int64_field_name, DataType.INT64)
schema.add_field(ct.default_float_field_name, DataType.FLOAT)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=dim)
self.create_collection(client, collection_name, schema=schema)
# Insert data
data = cf.gen_row_data_by_schema(nb=ct.default_nb, schema=schema)
insert_res, _ = self.insert(client, collection_name, data=data)
ids = insert_res["ids"]
self.flush(client, collection_name)
# 2. create index
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name,
index_type="DISKANN", metric_type="L2", params={})
idx.add_index(field_name=ct.default_string_field_name, index_type="")
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
# 3. search with expr (use varchar PK filter — gen_row_data_by_schema generates "0","1","2",...)
default_expr = f'{ct.default_string_field_name} in ["0", "1", "2", "3"]'
limit = 4
default_search_params = {"metric_type": "L2", "params": {"search_list": 30}}
vectors = cf.gen_vectors(default_nq, dim)
output_fields = [default_int64_field_name,
default_float_field_name, default_string_field_name]
self.search(client, collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=limit,
filter=default_expr,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"ids": ids,
"limit": limit,
"metric": "L2",
"pk_name": ct.default_string_field_name,
"enable_milvus_client_api": True})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,335 @@
import pytest
from pymilvus import DataType
from common.common_type import CaseLabel, CheckTasks
from common import common_type as ct
from common import common_func as cf
from base.client_v2_base import TestMilvusClientV2Base
default_nb = ct.default_nb
default_dim = ct.default_dim
@pytest.mark.xdist_group("TestSearchIteratorShared")
@pytest.mark.tags(CaseLabel.GPU)
class TestSearchIteratorShared(TestMilvusClientV2Base):
"""Shared collection for search iterator tests.
Schema: int64(PK), float, varchar(65535), json, float_vector(128), dynamic=False
Data: 3000 rows
Index: COSINE on float_vector
"""
shared_alias = "TestSearchIteratorShared"
def setup_class(self):
super().setup_class(self)
self.collection_name = "TestSearchIteratorShared" + cf.gen_unique_str("search_iterator")
@pytest.fixture(scope="class", autouse=True)
def prepare_collection(self, request):
client = self._client(alias=self.shared_alias)
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True)
schema.add_field(ct.default_float_field_name, DataType.FLOAT)
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
self.create_collection(client, self.collection_name, schema=schema, force_teardown=False)
data = cf.gen_row_data_by_schema(nb=3000, schema=schema)
self.insert(client, self.collection_name, data=data)
self.flush(client, self.collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, metric_type="COSINE")
self.create_index(client, self.collection_name, index_params=idx)
self.load_collection(client, self.collection_name)
def teardown():
self.drop_collection(self._client(alias=self.shared_alias), self.collection_name)
request.addfinalizer(teardown)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("batch_size", [10, 100, 777, 1000])
def test_search_iterator_with_different_batch_size(self, batch_size):
"""
target: verify search iterator returns correct batch sizes with unique PKs
method: 1. run search iterator with various batch_size values on shared COSINE collection
2. check batch_size constraint via check_search_iterator
expected: each batch batch_size, all PKs unique across batches
"""
client = self._client(alias=self.shared_alias)
search_vectors = cf.gen_vectors(1, default_dim)
search_params = {"metric_type": "COSINE"}
self.search_iterator(client, self.collection_name, data=search_vectors,
batch_size=batch_size,
search_params=search_params,
anns_field=ct.default_float_vec_field_name,
check_task=CheckTasks.check_search_iterator,
check_items={"batch_size": batch_size})
@pytest.mark.tags(CaseLabel.L2)
def test_search_iterator_invalid_nq(self):
"""
target: verify search iterator rejects nq > 1 (multiple vectors)
method: 1. run search iterator with 2 vectors on shared collection
expected: error indicating multiple vectors not supported
"""
client = self._client(alias=self.shared_alias)
batch_size = 100
search_vectors = cf.gen_vectors(2, default_dim)
search_params = {"metric_type": "COSINE"}
self.search_iterator(client, self.collection_name, data=search_vectors,
batch_size=batch_size,
search_params=search_params,
anns_field=ct.default_float_vec_field_name,
check_task=CheckTasks.err_res,
check_items={"err_code": 1,
"err_msg": "does not support processing multiple vectors"})
@pytest.mark.tags(CaseLabel.L2)
def test_search_iterator_not_support_search_by_pk(self):
"""
target: verify search iterator does not support search-by-pk
method: 1. search iterator with data=None + ids error (NoneType)
2. search iterator with data + ids error (both provided)
expected: both cases return error
"""
client = self._client(alias=self.shared_alias)
batch_size = 100
search_vectors = cf.gen_vectors(1, default_dim)
search_params = {"metric_type": "COSINE"}
ids_to_search = [1]
self.search_iterator(client, self.collection_name,
data=None,
batch_size=batch_size,
search_params=search_params,
anns_field=ct.default_float_vec_field_name,
ids=ids_to_search,
check_task=CheckTasks.err_res,
check_items={"err_code": 999,
"err_msg": "object of type 'NoneType' has no len()"})
self.search_iterator(client, self.collection_name,
data=search_vectors,
batch_size=batch_size,
search_params=search_params,
anns_field=ct.default_float_vec_field_name,
ids=ids_to_search,
check_task=CheckTasks.err_res,
check_items={"err_code": 999,
"err_msg": "Either ids or data must be provided, not both"})
@pytest.mark.tags(CaseLabel.L2)
def test_search_iterator_with_expression(self):
"""
target: verify search iterator with expression filter returns correct batches (COSINE)
method: 1. run search iterator with filter "1000 <= int64 < 2000" on shared collection
2. check batch_size via check_search_iterator
expected: iterator returns batches of correct size with unique PKs
"""
client = self._client(alias=self.shared_alias)
batch_size = 100
search_vectors = cf.gen_vectors(1, default_dim)
search_params = {"metric_type": "COSINE"}
expression = f"1000 <= {ct.default_int64_field_name} < 2000"
self.search_iterator(client, self.collection_name, data=search_vectors,
batch_size=batch_size,
search_params=search_params,
anns_field=ct.default_float_vec_field_name,
filter=expression,
check_task=CheckTasks.check_search_iterator,
check_items={"batch_size": batch_size,
"pk_range": (1000, 2000)})
@pytest.mark.tags(CaseLabel.L2)
def test_range_search_iterator_cosine(self):
"""
target: verify range search iterator works with COSINE metric on shared collection
method: 1. regular search to get distance reference points
2. run range search iterator with radius/range_filter derived from step 1
3. check range constraints in iterator results
expected: range iterator results within [radius, range_filter], batches correct size
"""
client = self._client(alias=self.shared_alias)
batch_size = 100
limit = 200
search_vector = cf.gen_vectors(1, default_dim)
search_params = {"metric_type": "COSINE"}
# get distance reference from regular search
res = self.search(client, self.collection_name,
data=search_vector,
anns_field=ct.default_float_vec_field_name,
search_params=search_params,
limit=limit,
check_task=CheckTasks.check_search_results,
check_items={"nq": 1, "limit": limit,
"metric": "COSINE",
"enable_milvus_client_api": True,
"pk_name": ct.default_int64_field_name})[0]
# COSINE: higher distance = more similar, so radius < range_filter
radius = res[0][limit // 2]["distance"] - 0.1
range_filter = res[0][0]["distance"] + 0.1
range_search_params = {"metric_type": "COSINE",
"params": {"radius": radius, "range_filter": range_filter}}
self.search_iterator(client, self.collection_name, data=search_vector,
batch_size=batch_size,
search_params=range_search_params,
anns_field=ct.default_float_vec_field_name,
check_task=CheckTasks.check_search_iterator,
check_items={"metric_type": "COSINE",
"batch_size": batch_size,
"radius": radius,
"range_filter": range_filter})
class TestSearchIteratorIndependent(TestMilvusClientV2Base):
"""Independent tests for search iterator scenarios requiring unique schemas
(different metrics, vector types, range search, binary vectors)
"""
@pytest.mark.tags(CaseLabel.L0)
@pytest.mark.parametrize("metric_type", ["L2", "IP"])
@pytest.mark.parametrize("vector_data_type", ct.all_dense_vector_types)
def test_range_search_iterator_default(self, metric_type, vector_data_type):
"""
target: verify iterator and range search iterator work across all dense metrics and vector types
method: 1. create collection with given vector_data_type, build index with metric_type
2. run basic search iterator, check batch_size and metric ordering
3. run regular search to get distance reference points
4. run range search iterator with radius/range_filter derived from step 3
5. check range constraints in iterator results
expected: iterator respects batch_size; range iterator results within [radius, range_filter]
"""
batch_size = 100
dim = default_dim
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True)
schema.add_field(ct.default_float_field_name, DataType.FLOAT)
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, vector_data_type, dim=dim)
self.create_collection(client, collection_name, schema=schema)
data = cf.gen_row_data_by_schema(nb=default_nb, schema=schema)
self.insert(client, collection_name, data=data)
self.flush(client, collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, metric_type=metric_type)
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
search_vector = cf.gen_vectors(1, dim, vector_data_type)
search_params = {"metric_type": metric_type}
self.search_iterator(client, collection_name, data=search_vector,
batch_size=batch_size,
search_params=search_params,
anns_field=ct.default_float_vec_field_name,
check_task=CheckTasks.check_search_iterator,
check_items={"metric_type": metric_type,
"batch_size": batch_size})
limit = 200
res = self.search(client, collection_name,
data=search_vector,
anns_field=ct.default_float_vec_field_name,
search_params=search_params,
limit=limit,
check_task=CheckTasks.check_search_results,
check_items={"nq": 1, "limit": limit,
"metric": metric_type,
"enable_milvus_client_api": True,
"pk_name": ct.default_int64_field_name})[0]
# range search iterator with radius/range_filter derived from regular search distances
if metric_type != "L2":
radius = res[0][limit // 2]["distance"] - 0.1
range_filter = res[0][0]["distance"] + 0.1
else:
radius = res[0][limit // 2]["distance"] + 0.1
range_filter = res[0][0]["distance"] - 0.1
range_search_params = {"metric_type": metric_type,
"params": {"radius": radius, "range_filter": range_filter}}
self.search_iterator(client, collection_name, data=search_vector,
batch_size=batch_size,
search_params=range_search_params,
anns_field=ct.default_float_vec_field_name,
check_task=CheckTasks.check_search_iterator,
check_items={"metric_type": metric_type, "batch_size": batch_size,
"radius": radius,
"range_filter": range_filter})
@pytest.mark.tags(CaseLabel.L1)
def test_search_iterator_binary(self):
"""
target: verify search iterator works with binary vectors (BIN_FLAT/JACCARD)
method: 1. create collection with binary vector, insert data
2. run search iterator with JACCARD metric
3. check batch_size via check_search_iterator
expected: iterator returns batches of correct size with unique PKs
"""
batch_size = 200
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
schema = self.create_schema(client)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True)
schema.add_field(ct.default_float_field_name, DataType.FLOAT)
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_binary_vec_field_name, DataType.BINARY_VECTOR, dim=ct.default_dim)
self.create_collection(client, collection_name, schema=schema)
data = cf.gen_row_data_by_schema(nb=default_nb, schema=schema)
self.insert(client, collection_name, data=data)
self.flush(client, collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_binary_vec_field_name, index_type="BIN_FLAT",
metric_type="JACCARD")
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
_, binary_search_vectors = cf.gen_binary_vectors(1, ct.default_dim)
self.search_iterator(client, collection_name, data=binary_search_vectors,
batch_size=batch_size,
search_params=ct.default_search_binary_params,
anns_field=ct.default_binary_vec_field_name,
check_task=CheckTasks.check_search_iterator,
check_items={"batch_size": batch_size})
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("metric_type", ["L2", "IP"])
def test_search_iterator_with_expression(self, metric_type):
"""
target: verify search iterator with expression filter works with L2/IP metrics
method: 1. create collection with given metric, insert data
2. run search iterator with filter "1000 <= int64 < 2000"
3. check batch_size via check_search_iterator
expected: iterator returns batches of correct size with unique PKs
"""
batch_size = 100
dim = ct.default_dim
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True)
schema.add_field(ct.default_float_field_name, DataType.FLOAT)
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=dim)
self.create_collection(client, collection_name, schema=schema)
data = cf.gen_row_data_by_schema(nb=default_nb, schema=schema)
self.insert(client, collection_name, data=data)
self.flush(client, collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, metric_type=metric_type)
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
search_vectors = cf.gen_vectors(1, dim)
search_params = {"metric_type": metric_type}
expression = f"1000 <= {ct.default_int64_field_name} < 2000"
self.search_iterator(client, collection_name, data=search_vectors,
batch_size=batch_size,
search_params=search_params,
anns_field=ct.default_float_vec_field_name,
filter=expression,
check_task=CheckTasks.check_search_iterator,
check_items={"batch_size": batch_size,
"pk_range": (1000, 2000)})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,615 @@
import pytest
from pymilvus import DataType
from common.common_type import CaseLabel, CheckTasks
from common import common_type as ct
from common import common_func as cf
from base.client_v2_base import TestMilvusClientV2Base
default_dim = ct.default_dim
default_limit = ct.default_limit
default_search_field = ct.default_float_vec_field_name
default_search_params = ct.default_search_params
field_name = ct.default_float_vec_field_name
# Sentinel values for expected search results
NOT_LOADED = "not_loaded"
NOT_FOUND = "not_found"
# Special sentinel: search on [p1, p2] with a specific error code/msg
NOT_LOADED_999 = "not_loaded_999"
NOT_FOUND_999 = "not_found_999"
class TestSearchLoadIndependent(TestMilvusClientV2Base):
""" Test case of search combining load and other functions """
def _create_collection_with_partitions_and_data(self, client, nb=200, partition_num=1,
is_index=False, dim=default_dim):
"""
Helper: create a collection with default schema, partition_num extra partitions,
insert data split evenly across all partitions (including _default), optionally create
index and load.
Returns (collection_name, partition_names_list, data_per_partition_count).
partition_names_list[0] = "_default", partition_names_list[1] = "search_partition_0", etc.
"""
collection_name = cf.gen_collection_name_by_testcase_name()
# Create schema
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True)
schema.add_field(ct.default_float_field_name, DataType.FLOAT)
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=dim)
self.create_collection(client, collection_name, schema=schema)
# Create extra partitions
partition_names = ["_default"]
for i in range(partition_num):
p_name = f"search_partition_{i}"
self.create_partition(client, collection_name, partition_name=p_name)
partition_names.append(p_name)
total_partitions = len(partition_names)
per_partition = nb // total_partitions
# Insert data evenly across partitions
if nb > 0:
start = 0
for p_name in partition_names:
data = cf.gen_default_rows_data(nb=per_partition, dim=dim, start=start, with_json=True)
self.insert(client, collection_name, data=data, partition_name=p_name)
start += per_partition
self.flush(client, collection_name)
if is_index:
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, metric_type="COSINE",
index_type="FLAT", params={})
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
return collection_name, partition_names, per_partition
def _create_index_flat(self, client, collection_name):
"""Helper: create a FLAT index on the default float vector field."""
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, metric_type="COSINE",
index_type="FLAT", params={})
self.create_index(client, collection_name, index_params=idx)
def _do_search(self, client, collection_name, partition_names, limit, expected):
"""Execute a single search and verify against expected result."""
if expected == NOT_LOADED:
self.search(client, collection_name, data=cf.gen_vectors(1, default_dim),
anns_field=field_name, search_params=default_search_params, limit=limit,
partition_names=partition_names,
check_task=CheckTasks.err_res,
check_items={ct.err_code: 1, ct.err_msg: 'not loaded',
"enable_milvus_client_api": True})
elif expected == NOT_FOUND:
self.search(client, collection_name, data=cf.gen_vectors(1, default_dim),
anns_field=field_name, search_params=default_search_params, limit=limit,
partition_names=partition_names,
check_task=CheckTasks.err_res,
check_items={ct.err_code: 1, ct.err_msg: 'not found',
"enable_milvus_client_api": True})
elif expected == NOT_LOADED_999:
self.search(client, collection_name, data=cf.gen_vectors(1, default_dim),
anns_field=field_name, search_params=default_search_params, limit=limit,
partition_names=partition_names,
check_task=CheckTasks.err_res,
check_items={ct.err_code: 999,
ct.err_msg: 'failed to search: collection not loaded',
"enable_milvus_client_api": True})
elif expected == NOT_FOUND_999:
# partition not found with error code 999, msg includes partition name
p2_name = partition_names[-1] if partition_names else ""
self.search(client, collection_name, data=cf.gen_vectors(1, default_dim),
anns_field=field_name, search_params=default_search_params, limit=limit,
partition_names=partition_names,
check_task=CheckTasks.err_res,
check_items={ct.err_code: 999,
ct.err_msg: f'partition name {p2_name} not found',
"enable_milvus_client_api": True})
elif isinstance(expected, str) and expected == "not_loaded_65535":
self.search(client, collection_name, data=cf.gen_vectors(1, default_dim),
anns_field=field_name, search_params=default_search_params, limit=limit,
partition_names=partition_names,
check_task=CheckTasks.err_res,
check_items={ct.err_code: 65535,
ct.err_msg: "collection not loaded",
"enable_milvus_client_api": True})
else:
# expected is an integer: the expected result count
self.search(client, collection_name, data=cf.gen_vectors(1, default_dim),
anns_field=field_name, search_params=default_search_params, limit=limit,
partition_names=partition_names,
check_task=CheckTasks.check_search_results,
check_items={"nq": 1, "limit": expected, "enable_milvus_client_api": True,
"metric": "COSINE", "pk_name": ct.default_int64_field_name})
def _run_search_assertions(self, client, collection_name, p1_name, p2_name, limit,
expected_collection, expected_p1, expected_p2,
search_collection_partitions=None):
"""
Run search on collection (no partition filter), p1, and p2 and verify expected results.
search_collection_partitions: if set, override partition_names for the collection-level search.
"""
# Search on collection
coll_partitions = search_collection_partitions
self._do_search(client, collection_name, coll_partitions, limit, expected_collection)
# Search on p1
self._do_search(client, collection_name, [p1_name], limit, expected_p1)
# Search on p2
self._do_search(client, collection_name, [p2_name], limit, expected_p2)
# ==================================================================================
# Group 1: Delete-based tests (nb=200, partition_num=1, delete IDs 50-150)
# Each test: create collection, create index, execute ops, then search on collection/p1/p2
# ==================================================================================
# Parameters: (test_name, ops_sequence, expected_collection, expected_p1, expected_p2,
# search_collection_partitions_override)
# ops_sequence is a list of operation tuples: (op_name, *args)
# search_collection_partitions_override: None means no partition filter on collection search,
# "both" means [p1, p2], "p1p2" means [p1, p2]
_DELETE_IDS = list(range(50, 150))
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("ops,expected_coll,expected_p1,expected_p2,coll_search_parts", [
# test_delete_load_collection_release_collection
pytest.param(
[("delete",), ("load_collection",), ("release_collection",), ("load_p2",)],
50, NOT_LOADED, 50, None,
id="delete_load_collection_release_collection_load_p2"),
# test_load_collection_delete_release_partition
pytest.param(
[("load_collection",), ("delete",), ("release_p1",)],
NOT_LOADED, NOT_LOADED, 50, "both",
id="load_collection_delete_release_partition"),
# test_load_partition_delete_release_collection (uses query, handled separately below)
# test_load_collection_release_partition_delete
pytest.param(
[("load_collection",), ("release_p1",), ("delete",)],
50, NOT_LOADED, 50, None,
id="load_collection_release_partition_delete"),
])
def test_search_after_delete_ops_l1(self, ops, expected_coll, expected_p1, expected_p2,
coll_search_parts):
"""Parametrized L1 tests: delete + load/release operation sequences."""
client = self._client()
collection_name, partition_names, _ = \
self._create_collection_with_partitions_and_data(client, nb=200, partition_num=1, is_index=False)
p1_name, p2_name = partition_names[0], partition_names[1]
self._create_index_flat(client, collection_name)
delete_ids = self._DELETE_IDS
for op in ops:
self._execute_op(client, collection_name, p1_name, p2_name, delete_ids, op)
parts = [p1_name, p2_name] if coll_search_parts == "both" else None
self._run_search_assertions(client, collection_name, p1_name, p2_name, 200,
expected_coll, expected_p1, expected_p2,
search_collection_partitions=parts)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("ops,expected_coll,expected_p1,expected_p2,coll_search_parts", [
# test_delete_load_collection_release_partition
pytest.param(
[("delete",), ("load_collection",), ("release_p1",)],
50, NOT_LOADED, 50, None,
id="delete_load_collection_release_partition"),
# test_delete_load_partition_release_collection
pytest.param(
[("delete",), ("load_p1",), ("release_collection",)],
NOT_LOADED, NOT_LOADED, NOT_LOADED, None,
id="delete_load_partition_release_collection"),
# test_delete_release_collection_load_partition
pytest.param(
[("delete",), ("load_p1",), ("release_collection",), ("load_p2",)],
50, NOT_LOADED, 50, None,
id="delete_release_collection_load_partition"),
# test_delete_load_partition_drop_partition
pytest.param(
[("delete",), ("load_p2",), ("release_p2",), ("drop_p2",)],
NOT_LOADED, NOT_LOADED, NOT_FOUND, None,
id="delete_load_partition_drop_partition"),
# test_load_partition_delete_drop_partition
pytest.param(
[("load_p1",), ("delete",), ("drop_p2",)],
50, 50, NOT_FOUND, None,
id="load_partition_delete_drop_partition"),
# test_load_partition_release_collection_delete
pytest.param(
[("load_p1",), ("release_collection",), ("delete",), ("load_collection",)],
100, 50, 50, "both",
id="load_partition_release_collection_delete"),
])
@pytest.mark.tags(CaseLabel.L2)
def test_search_after_delete_ops_l2(self, ops, expected_coll, expected_p1, expected_p2,
coll_search_parts):
"""Parametrized L2 tests: delete + load/release operation sequences."""
client = self._client()
collection_name, partition_names, _ = \
self._create_collection_with_partitions_and_data(client, nb=200, partition_num=1, is_index=False)
p1_name, p2_name = partition_names[0], partition_names[1]
self._create_index_flat(client, collection_name)
delete_ids = self._DELETE_IDS
for op in ops:
self._execute_op(client, collection_name, p1_name, p2_name, delete_ids, op)
parts = [p1_name, p2_name] if coll_search_parts == "both" else None
self._run_search_assertions(client, collection_name, p1_name, p2_name, 200,
expected_coll, expected_p1, expected_p2,
search_collection_partitions=parts)
@pytest.mark.tags(CaseLabel.L1)
def test_load_partition_delete_release_collection(self):
"""
target: test load partition, delete, release collection, reload partition, then query/search
method: Uses query (count) on collection and p1, search on p2
expected: count=50 on collection and p1, not_loaded on p2
"""
client = self._client()
collection_name, partition_names, _ = \
self._create_collection_with_partitions_and_data(client, nb=200, partition_num=1, is_index=False)
p1_name, p2_name = partition_names[0], partition_names[1]
self._create_index_flat(client, collection_name)
self.load_partitions(client, collection_name, partition_names=[p1_name])
delete_ids = self._DELETE_IDS
self.delete(client, collection_name, filter=f"{ct.default_int64_field_name} in {delete_ids}")
self.release_collection(client, collection_name)
self.load_partitions(client, collection_name, partition_names=[p1_name])
# query on collection and p1
self.query(client, collection_name, filter='',
output_fields=[ct.default_count_output],
check_task=CheckTasks.check_query_results,
check_items={"exp_res": [{ct.default_count_output: 50}],
"enable_milvus_client_api": True})
self.query(client, collection_name, filter='',
output_fields=[ct.default_count_output],
partition_names=[p1_name],
check_task=CheckTasks.check_query_results,
check_items={"exp_res": [{ct.default_count_output: 50}],
"enable_milvus_client_api": True})
self.search(client, collection_name, data=cf.gen_vectors(1, default_dim),
anns_field=field_name, search_params=default_search_params, limit=200,
partition_names=[p2_name],
check_task=CheckTasks.err_res,
check_items={ct.err_code: 1, ct.err_msg: 'not loaded',
"enable_milvus_client_api": True})
@pytest.mark.tags(CaseLabel.L2)
def test_load_partition_drop_partition_delete(self):
"""
target: test load partition drop partition delete (custom schema, no data inserted)
method: create collection with 2 custom partitions, load p2, release+drop p2, search
expected: p1+p2 search returns partition not found, p1 returns not loaded, p2 returns not found
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True)
schema.add_field(ct.default_float_field_name, DataType.FLOAT)
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
self.create_collection(client, collection_name, schema=schema)
p1_name = cf.gen_unique_str("par1")
self.create_partition(client, collection_name, partition_name=p1_name)
p2_name = cf.gen_unique_str("par2")
self.create_partition(client, collection_name, partition_name=p2_name)
self._create_index_flat(client, collection_name)
self.load_partitions(client, collection_name, partition_names=[p2_name])
self.release_partitions(client, collection_name, partition_names=[p2_name])
self.drop_partition(client, collection_name, partition_name=p2_name)
# search on [p1, p2]
self._do_search(client, collection_name, [p1_name, p2_name], 10, NOT_FOUND_999)
# search on p1
self._do_search(client, collection_name, [p1_name], 10, NOT_LOADED_999)
# search on p2
self._do_search(client, collection_name, [p2_name], 10, NOT_FOUND_999)
# ==================================================================================
# Group 2: Compact-based tests (nb=0, multi-batch insert, compact)
# Data: 200 rows in p1 (2 batches of 100), 100 rows in p2
# ==================================================================================
def _setup_compact_test(self, client):
"""Setup for compact tests: create collection, insert multi-batch, flush."""
collection_name, partition_names, _ = \
self._create_collection_with_partitions_and_data(client, nb=0, partition_num=1, is_index=True)
p1_name, p2_name = partition_names[0], partition_names[1]
self.release_collection(client, collection_name)
data1 = cf.gen_default_rows_data(nb=100, dim=default_dim, start=0, with_json=True)
self.insert(client, collection_name, data=data1, partition_name=p1_name)
data2 = cf.gen_default_rows_data(nb=100, dim=default_dim, start=100, with_json=True)
self.insert(client, collection_name, data=data2, partition_name=p1_name)
data3 = cf.gen_default_rows_data(nb=100, dim=default_dim, start=200, with_json=True)
self.insert(client, collection_name, data=data3, partition_name=p2_name)
self.flush(client, collection_name)
return collection_name, p1_name, p2_name
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("ops,expected_coll,expected_p1,expected_p2,coll_search_parts", [
# test_load_collection_release_partition_compact
pytest.param(
[("load_collection",), ("release_p1",), ("compact",)],
100, NOT_LOADED, 100, None,
id="load_collection_release_partition_compact"),
])
def test_search_after_compact_ops_l1(self, ops, expected_coll, expected_p1, expected_p2,
coll_search_parts):
"""Parametrized L1 tests: compact + load/release operation sequences."""
client = self._client()
collection_name, p1_name, p2_name = self._setup_compact_test(client)
for op in ops:
self._execute_compact_op(client, collection_name, p1_name, p2_name, op)
parts = [p1_name, p2_name] if coll_search_parts == "both" else None
self._run_search_assertions(client, collection_name, p1_name, p2_name, 300,
expected_coll, expected_p1, expected_p2,
search_collection_partitions=parts)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("ops,expected_coll,expected_p1,expected_p2,coll_search_parts", [
# test_compact_load_collection_release_partition
pytest.param(
[("compact",), ("load_collection",), ("release_p1",)],
100, NOT_LOADED, 100, None,
id="compact_load_collection_release_partition"),
# test_compact_load_collection_release_collection
pytest.param(
[("compact",), ("load_collection",), ("release_collection",), ("load_p1",)],
NOT_LOADED, 200, NOT_LOADED, "both",
id="compact_load_collection_release_collection_load_p1"),
# test_compact_load_partition_release_collection
pytest.param(
[("compact",), ("load_p2",), ("release_collection",), ("load_p1",), ("load_p2",)],
300, 200, 100, None,
id="compact_load_partition_release_collection_load_both"),
# test_load_collection_compact_drop_partition
pytest.param(
[("load_collection",), ("compact",), ("release_p2",), ("drop_p2",)],
200, 200, NOT_FOUND, None,
id="load_collection_compact_drop_partition"),
# test_load_partition_compact_release_collection
pytest.param(
[("load_p2",), ("compact",), ("release_collection",), ("release_p2",)],
NOT_LOADED, NOT_LOADED, NOT_LOADED, None,
id="load_partition_compact_release_collection"),
])
@pytest.mark.tags(CaseLabel.L2)
def test_search_after_compact_ops_l2(self, ops, expected_coll, expected_p1, expected_p2,
coll_search_parts):
"""Parametrized L2 tests: compact + load/release operation sequences."""
client = self._client()
collection_name, p1_name, p2_name = self._setup_compact_test(client)
for op in ops:
self._execute_compact_op(client, collection_name, p1_name, p2_name, op)
parts = [p1_name, p2_name] if coll_search_parts == "both" else None
self._run_search_assertions(client, collection_name, p1_name, p2_name, 300,
expected_coll, expected_p1, expected_p2,
search_collection_partitions=parts)
# ==================================================================================
# Group 3: Flush-based tests (nb=200, partition_num=1, flush + load/release)
# Each partition has 100 rows (200 / 2 partitions)
# ==================================================================================
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("ops,expected_coll,expected_p1,expected_p2,coll_search_parts", [
# test_load_partition_release_collection_flush
pytest.param(
[("flush",), ("load_p2",), ("release_collection",), ("flush",)],
NOT_LOADED, NOT_LOADED, NOT_LOADED, None,
id="load_partition_release_collection_flush"),
# test_load_partition_drop_partition_flush
pytest.param(
[("flush",), ("load_p2",), ("release_p2",), ("drop_p2",), ("flush",)],
NOT_LOADED, NOT_LOADED, NOT_FOUND, None,
id="load_partition_drop_partition_flush"),
])
def test_search_after_flush_ops_l1(self, ops, expected_coll, expected_p1, expected_p2,
coll_search_parts):
"""Parametrized L1 tests: flush + load/release operation sequences."""
client = self._client()
collection_name, partition_names, _ = \
self._create_collection_with_partitions_and_data(client, nb=200, partition_num=1, is_index=False)
p1_name, p2_name = partition_names[0], partition_names[1]
self._create_index_flat(client, collection_name)
for op in ops:
self._execute_op(client, collection_name, p1_name, p2_name, None, op)
parts = [p1_name, p2_name] if coll_search_parts == "both" else None
self._run_search_assertions(client, collection_name, p1_name, p2_name, 200,
expected_coll, expected_p1, expected_p2,
search_collection_partitions=parts)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("ops,expected_coll,expected_p1,expected_p2,coll_search_parts", [
# test_flush_load_collection_release_partition
pytest.param(
[("flush",), ("load_collection",), ("release_p1",)],
100, NOT_LOADED, 100, None,
id="flush_load_collection_release_partition"),
# test_flush_load_collection_release_collection
pytest.param(
[("flush",), ("load_collection",), ("release_collection",), ("load_p2",)],
100, NOT_LOADED, 100, None,
id="flush_load_collection_release_collection_load_p2"),
# test_flush_load_partition_release_collection
pytest.param(
[("flush",), ("load_p2",), ("release_collection",)],
NOT_LOADED, NOT_LOADED, NOT_LOADED, None,
id="flush_load_partition_release_collection"),
# test_flush_load_partition_drop_partition
pytest.param(
[("flush",), ("load_p2",), ("release_p2",), ("drop_p2",)],
NOT_FOUND, NOT_LOADED, NOT_FOUND, "both",
id="flush_load_partition_drop_partition"),
# test_flush_load_collection_drop_partition
pytest.param(
[("flush",), ("load_collection",), ("release_p2",), ("drop_p2",)],
100, 100, NOT_FOUND, None,
id="flush_load_collection_drop_partition"),
# test_load_collection_release_partition_flush
pytest.param(
[("load_collection",), ("release_p2",), ("flush",)],
100, 100, NOT_LOADED, None,
id="load_collection_release_partition_flush"),
# test_load_collection_release_collection_flush
pytest.param(
[("load_collection",), ("release_collection",), ("load_p2",), ("flush",)],
NOT_LOADED, NOT_LOADED, 100, "both",
id="load_collection_release_collection_flush_load_p2"),
# test_load_partition_flush_release_collection
pytest.param(
[("load_p2",), ("flush",), ("release_collection",)],
NOT_LOADED, NOT_LOADED, NOT_LOADED, "both",
id="load_partition_flush_release_collection"),
# test_load_collection_flush_drop_partition
pytest.param(
[("load_p1",), ("flush",), ("drop_p2",)],
100, 100, NOT_FOUND, None,
id="load_collection_flush_drop_partition"),
])
@pytest.mark.tags(CaseLabel.L2)
def test_search_after_flush_ops_l2(self, ops, expected_coll, expected_p1, expected_p2,
coll_search_parts):
"""Parametrized L2 tests: flush + load/release operation sequences."""
client = self._client()
collection_name, partition_names, _ = \
self._create_collection_with_partitions_and_data(client, nb=200, partition_num=1, is_index=False)
p1_name, p2_name = partition_names[0], partition_names[1]
self._create_index_flat(client, collection_name)
for op in ops:
self._execute_op(client, collection_name, p1_name, p2_name, None, op)
parts = [p1_name, p2_name] if coll_search_parts == "both" else None
self._run_search_assertions(client, collection_name, p1_name, p2_name, 200,
expected_coll, expected_p1, expected_p2,
search_collection_partitions=parts)
@pytest.mark.tags(CaseLabel.L2)
def test_load_collection_flush_release_partition(self):
"""
target: test load collection, flush, search (200 results), release partition, search again
method: load collection first, flush, verify 200 results, release p2, verify reduced results
expected: first search returns 200, after release p2 returns 100 on collection, p1=100, p2=not_loaded
"""
client = self._client()
collection_name, partition_names, _ = \
self._create_collection_with_partitions_and_data(client, nb=200, partition_num=1, is_index=False)
p1_name, p2_name = partition_names[0], partition_names[1]
self._create_index_flat(client, collection_name)
self.load_collection(client, collection_name)
self.flush(client, collection_name)
# first search: all loaded, 200 results
self._do_search(client, collection_name, None, 200, 200)
# release p2
self.release_partitions(client, collection_name, partition_names=[p2_name])
# search after release
self._do_search(client, collection_name, None, 200, 100)
self._do_search(client, collection_name, [p1_name], 200, 100)
self._do_search(client, collection_name, [p2_name], 200, NOT_LOADED)
# ==================================================================================
# Group 4: Special / miscellaneous tests
# ==================================================================================
@pytest.mark.tags(CaseLabel.L2)
def test_load_release_collection_multi_times(self):
"""
target: test load and release multiple times
method: release and load p2 five times, then search
expected: collection=100 (p2 only), p1=not_loaded, p2=100
"""
client = self._client()
collection_name, partition_names, _ = \
self._create_collection_with_partitions_and_data(client, nb=200, partition_num=1, is_index=False)
p1_name, p2_name = partition_names[0], partition_names[1]
self._create_index_flat(client, collection_name)
for _ in range(5):
self.release_collection(client, collection_name)
self.load_partitions(client, collection_name, partition_names=[p2_name])
self._run_search_assertions(client, collection_name, p1_name, p2_name, 200,
100, NOT_LOADED, 100)
@pytest.mark.tags(CaseLabel.L2)
def test_load_collection_release_all_partitions(self):
"""
target: test load and release all partitions
method: load collection and release all partitions one by one
expected: collection search returns error code 65535, collection not loaded
"""
client = self._client()
collection_name, partition_names, _ = \
self._create_collection_with_partitions_and_data(client, nb=200, partition_num=1, is_index=False)
p1_name, p2_name = partition_names[0], partition_names[1]
self._create_index_flat(client, collection_name)
self.load_collection(client, collection_name)
self.release_partitions(client, collection_name, partition_names=[p1_name])
self.release_partitions(client, collection_name, partition_names=[p2_name])
self._do_search(client, collection_name, None, 200, "not_loaded_65535")
@pytest.mark.tags(CaseLabel.L2)
def test_search_load_collection_create_partition(self):
"""
target: test load collection and create partition and search
method: load collection, create a new partition, search
expected: search returns 200 results
"""
client = self._client()
collection_name, _, _ = \
self._create_collection_with_partitions_and_data(client, nb=200, partition_num=1, is_index=False)
self._create_index_flat(client, collection_name)
self.load_collection(client, collection_name)
self.create_partition(client, collection_name, partition_name=cf.gen_unique_str("partition3"))
self._do_search(client, collection_name, None, 200, 200)
@pytest.mark.tags(CaseLabel.L2)
def test_search_load_partition_create_partition(self):
"""
target: test load partition and create partition and search
method: load p1, create a new partition, search
expected: search returns 100 results (p1 only)
"""
client = self._client()
collection_name, partition_names, _ = \
self._create_collection_with_partitions_and_data(client, nb=200, partition_num=1, is_index=False)
p1_name = partition_names[0]
self._create_index_flat(client, collection_name)
self.load_partitions(client, collection_name, partition_names=[p1_name])
self.create_partition(client, collection_name, partition_name=cf.gen_unique_str("partition3"))
self._do_search(client, collection_name, None, 200, 100)
# ==================================================================================
# Operation executors
# ==================================================================================
def _execute_op(self, client, collection_name, p1_name, p2_name, delete_ids, op):
"""Execute a single operation tuple for delete/flush-based tests."""
op_name = op[0]
if op_name == "delete":
self.delete(client, collection_name,
filter=f"{ct.default_int64_field_name} in {delete_ids}")
elif op_name == "load_collection":
self.load_collection(client, collection_name)
elif op_name == "release_collection":
self.release_collection(client, collection_name)
elif op_name == "release_p1":
self.release_partitions(client, collection_name, partition_names=[p1_name])
elif op_name == "release_p2":
self.release_partitions(client, collection_name, partition_names=[p2_name])
elif op_name == "load_p1":
self.load_partitions(client, collection_name, partition_names=[p1_name])
elif op_name == "load_p2":
self.load_partitions(client, collection_name, partition_names=[p2_name])
elif op_name == "drop_p2":
self.drop_partition(client, collection_name, partition_name=p2_name)
elif op_name == "flush":
self.flush(client, collection_name)
elif op_name == "compact":
self.compact(client, collection_name)
else:
raise ValueError(f"Unknown operation: {op_name}")
def _execute_compact_op(self, client, collection_name, p1_name, p2_name, op):
"""Execute a single operation tuple for compact-based tests."""
# Reuses the same logic
self._execute_op(client, collection_name, p1_name, p2_name, None, op)
@@ -0,0 +1,667 @@
import numpy as np
import pytest
from pymilvus import DataType
from utils.util_pymilvus import *
from common.common_type import CaseLabel, CheckTasks
from common import common_type as ct
from common import common_func as cf
from utils.util_log import test_log as log
from base.client_v2_base import TestMilvusClientV2Base
default_nb = ct.default_nb
default_nq = ct.default_nq
default_dim = ct.default_dim
default_limit = ct.default_limit
default_search_exp = "int64 >= 0"
default_search_field = ct.default_float_vec_field_name
default_search_params = ct.default_search_params
class TestSearchNoneDefaultIndependent(TestMilvusClientV2Base):
"""Independent tests for nullable field and default-value search scenarios.
Each test creates its own collection because nullable/default-value configs
and vector_data_type vary per test case.
"""
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("auto_id", [False, True])
@pytest.mark.parametrize("is_flush", [False, True])
@pytest.mark.parametrize("enable_dynamic_field", [True, False])
@pytest.mark.parametrize("vector_data_type", ct.all_dense_vector_types)
def test_search_normal_none_data(self, auto_id, is_flush, enable_dynamic_field, vector_data_type):
"""
target: verify search works correctly with nullable float field at various null ratios
method: 1. create collection with nullable float field
2. insert data with null_data_percent nulls
3. search with filter "int64 >= 0" and output nullable field
4. check nq, limit, IDs, output_fields, distance order via check_task
expected: search returns correct results with distances in COSINE order
"""
nq = 200
dim = ct.default_dim
null_data_percent = 0.5
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
schema = self.create_schema(client, enable_dynamic_field=enable_dynamic_field)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True, auto_id=auto_id)
schema.add_field(ct.default_float_field_name, DataType.FLOAT, nullable=True)
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, vector_data_type, dim=dim)
self.create_collection(client, collection_name, schema=schema)
data = cf.gen_default_rows_data(nb=default_nb, dim=dim, auto_id=auto_id, with_json=True,
vector_data_type=vector_data_type,
nullable_fields={ct.default_float_field_name: null_data_percent})
insert_res, _ = self.insert(client, collection_name, data=data)
insert_ids = insert_res["ids"]
if is_flush:
self.flush(client, collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, metric_type="COSINE")
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
vectors = cf.gen_vectors(nq, dim, vector_data_type)
self.search(client, collection_name,
data=vectors[:nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=default_search_exp,
output_fields=[ct.default_int64_field_name,
ct.default_float_field_name],
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": nq,
"ids": insert_ids,
"pk_name": ct.default_int64_field_name,
"limit": default_limit,
"metric": "COSINE",
"output_fields": [ct.default_int64_field_name,
ct.default_float_field_name]})
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("varchar_scalar_index", ["TRIE", "INVERTED", "BITMAP"])
@pytest.mark.parametrize("numeric_scalar_index", ["STL_SORT", "INVERTED"])
def test_search_after_none_data_all_field_datatype(self, varchar_scalar_index, numeric_scalar_index):
"""
target: verify search works with nullable fields across all scalar types and different index types
method: 1. create collection with all scalar data types, all nullable at given ratio
2. create HNSW vector index + scalar indexes (varchar/numeric/bool)
3. search with filter and output nullable fields
4. check nq, limit, IDs, output_fields via check_task
expected: search returns correct results with distances in COSINE order
"""
null_data_percent = 0.5
nullable_fields = {ct.default_int32_field_name: null_data_percent,
ct.default_int16_field_name: null_data_percent,
ct.default_int8_field_name: null_data_percent,
ct.default_bool_field_name: null_data_percent,
ct.default_float_field_name: null_data_percent,
ct.default_double_field_name: null_data_percent,
ct.default_string_field_name: null_data_percent}
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
default_schema = cf.gen_collection_schema_all_datatype(auto_id=False, dim=default_dim,
enable_dynamic_field=False,
nullable_fields=nullable_fields)
self.create_collection(client, collection_name, schema=default_schema)
data = cf.gen_default_rows_data_all_data_type(nb=3000, dim=default_dim)
for field_key, percent in nullable_fields.items():
null_number = int(3000 * percent)
for row in data[-null_number:]:
if field_key in row:
row[field_key] = None
insert_res, _ = self.insert(client, collection_name, data=data)
insert_ids = insert_res["ids"]
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, index_type="HNSW",
metric_type="COSINE", params=cf.get_index_params_params("HNSW"))
self.create_index(client, collection_name, index_params=idx)
scalar_idx = self.prepare_index_params(client)[0]
scalar_idx.add_index(field_name=ct.default_string_field_name, index_type=varchar_scalar_index)
self.create_index(client, collection_name, index_params=scalar_idx)
for scalar_field in [ct.default_int64_field_name, ct.default_int32_field_name,
ct.default_int16_field_name, ct.default_int8_field_name,
ct.default_float_field_name]:
scalar_idx2 = self.prepare_index_params(client)[0]
scalar_idx2.add_index(field_name=scalar_field, index_type=numeric_scalar_index)
self.create_index(client, collection_name, index_params=scalar_idx2)
bool_idx = self.prepare_index_params(client)[0]
bool_idx.add_index(field_name=ct.default_bool_field_name, index_type="INVERTED")
self.create_index(client, collection_name, index_params=bool_idx)
self.load_collection(client, collection_name)
search_params = {}
limit = ct.default_limit
vectors = cf.gen_vectors(default_nq, default_dim)
self.search(client, collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=search_params,
limit=limit,
filter=default_search_exp,
output_fields=[ct.default_string_field_name, ct.default_float_field_name],
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": default_nq,
"ids": insert_ids,
"limit": limit,
"metric": "COSINE",
"pk_name": ct.default_int64_field_name,
"output_fields": [ct.default_string_field_name,
ct.default_float_field_name]})
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("dim", [32, 128])
@pytest.mark.parametrize("auto_id", [False, True])
@pytest.mark.parametrize("is_flush", [False, True])
@pytest.mark.parametrize("enable_dynamic_field", [True, False])
@pytest.mark.parametrize("vector_data_type", ct.all_dense_vector_types)
def test_search_default_value_with_insert(self, dim, auto_id, is_flush, enable_dynamic_field, vector_data_type):
"""
target: verify search works on collection with default_value field when data IS inserted with the field
method: 1. create collection with float field having default_value=10.0
2. insert data (float field included in rows, so default NOT triggered)
3. search and verify nq, limit, IDs, output_fields, distance order
expected: search returns correct results with distances in COSINE order
"""
nq = 200
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
schema = self.create_schema(client, enable_dynamic_field=enable_dynamic_field)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True, auto_id=auto_id)
schema.add_field(ct.default_float_field_name, DataType.FLOAT, default_value=np.float32(10.0))
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, vector_data_type, dim=dim)
self.create_collection(client, collection_name, schema=schema)
data = cf.gen_default_rows_data(nb=default_nb, dim=dim, auto_id=auto_id, with_json=True,
vector_data_type=vector_data_type)
insert_res, _ = self.insert(client, collection_name, data=data)
insert_ids = insert_res["ids"]
if is_flush:
self.flush(client, collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, metric_type="COSINE")
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
vectors = cf.gen_vectors(nq, dim, vector_data_type)
self.search(client, collection_name,
data=vectors[:nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=default_search_exp,
output_fields=[ct.default_int64_field_name,
ct.default_float_field_name],
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": nq,
"ids": insert_ids,
"pk_name": ct.default_int64_field_name,
"limit": default_limit,
"metric": "COSINE",
"output_fields": [ct.default_int64_field_name,
ct.default_float_field_name]})
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("enable_dynamic_field", [True, False])
def test_search_default_value_without_insert(self, enable_dynamic_field):
"""
target: verify search returns empty results on collection with default_value but no data
method: 1. create collection with nullable float field + default_value=10.0
2. do NOT insert any data
3. search and verify limit=0 (empty collection)
expected: search returns 0 results
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
schema = self.create_schema(client, enable_dynamic_field=enable_dynamic_field)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True)
schema.add_field(ct.default_float_field_name, DataType.FLOAT, nullable=True,
default_value=np.float32(10.0))
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
self.create_collection(client, collection_name, schema=schema)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, metric_type="COSINE")
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
vectors = cf.gen_vectors(default_nq, default_dim, vector_data_type=DataType.FLOAT_VECTOR)
self.search(client, collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=default_search_exp,
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": default_nq,
"pk_name": ct.default_int64_field_name,
"limit": 0})
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("varchar_scalar_index", ["TRIE", "INVERTED", "BITMAP"])
@pytest.mark.parametrize("numeric_scalar_index", ["STL_SORT", "INVERTED"])
def test_search_after_default_data_all_field_datatype(self, varchar_scalar_index, numeric_scalar_index):
"""
target: verify search works with default_value fields across all scalar types and different index types
method: 1. create collection with all scalar types having default values
2. create HNSW vector index + scalar indexes
3. search with filter and output all scalar fields
4. check nq, limit, IDs, output_fields via check_task
expected: search returns correct results with distances in L2 order
"""
default_value_fields = {ct.default_int32_field_name: np.int32(1),
ct.default_int16_field_name: np.int32(2),
ct.default_int8_field_name: np.int32(3),
ct.default_bool_field_name: True,
ct.default_float_field_name: np.float32(10.0),
ct.default_double_field_name: 10.0,
ct.default_string_field_name: "1"}
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
default_schema = cf.gen_collection_schema_all_datatype(auto_id=False, dim=default_dim,
enable_dynamic_field=False,
default_value_fields=default_value_fields)
self.create_collection(client, collection_name, schema=default_schema)
data = cf.gen_default_rows_data_all_data_type(nb=5000, dim=default_dim)
insert_res, _ = self.insert(client, collection_name, data=data)
insert_ids = insert_res["ids"]
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, index_type="HNSW",
metric_type="L2", params=cf.get_index_params_params("HNSW"))
self.create_index(client, collection_name, index_params=idx)
scalar_idx = self.prepare_index_params(client)[0]
scalar_idx.add_index(field_name=ct.default_string_field_name, index_type=varchar_scalar_index)
self.create_index(client, collection_name, index_params=scalar_idx)
for scalar_field in [ct.default_int64_field_name, ct.default_int32_field_name,
ct.default_int16_field_name, ct.default_int8_field_name,
ct.default_float_field_name]:
scalar_idx2 = self.prepare_index_params(client)[0]
scalar_idx2.add_index(field_name=scalar_field, index_type=numeric_scalar_index)
self.create_index(client, collection_name, index_params=scalar_idx2)
if numeric_scalar_index != "STL_SORT":
bool_idx = self.prepare_index_params(client)[0]
bool_idx.add_index(field_name=ct.default_bool_field_name, index_type=numeric_scalar_index)
self.create_index(client, collection_name, index_params=bool_idx)
self.load_collection(client, collection_name)
search_params = {}
limit = ct.default_limit
vectors = cf.gen_vectors(default_nq, default_dim)
output_fields = [ct.default_int64_field_name, ct.default_int32_field_name,
ct.default_int16_field_name, ct.default_int8_field_name,
ct.default_bool_field_name, ct.default_float_field_name,
ct.default_double_field_name, ct.default_string_field_name]
self.search(client, collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=search_params,
limit=limit,
filter=default_search_exp,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": default_nq,
"ids": insert_ids,
"pk_name": ct.default_int64_field_name,
"limit": limit,
"metric": "L2",
"output_fields": output_fields})
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("dim", [32, 128])
@pytest.mark.parametrize("auto_id", [False, True])
@pytest.mark.parametrize("is_flush", [False, True])
@pytest.mark.parametrize("enable_dynamic_field", [True, False])
@pytest.mark.parametrize("vector_data_type", ct.all_dense_vector_types)
def test_search_both_default_value_non_data(self, dim, auto_id, is_flush, enable_dynamic_field,
vector_data_type):
"""
target: verify search works when nullable+default_value float field is inserted with all None values
method: 1. create collection with float field: nullable=True + default_value=10.0
2. insert data with null_data_percent=1 (all float values are None default applies)
3. search and verify results
expected: search returns correct results with distances in COSINE order
"""
nq = 200
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
schema = self.create_schema(client, enable_dynamic_field=enable_dynamic_field)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True, auto_id=auto_id)
schema.add_field(ct.default_float_field_name, DataType.FLOAT, nullable=True,
default_value=np.float32(10.0))
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, vector_data_type, dim=dim)
self.create_collection(client, collection_name, schema=schema)
# null_data_percent=1 means all float field values are None
data = cf.gen_default_rows_data(nb=default_nb, dim=dim, auto_id=auto_id, with_json=True,
vector_data_type=vector_data_type,
nullable_fields={ct.default_float_field_name: 1})
insert_res, _ = self.insert(client, collection_name, data=data)
insert_ids = insert_res["ids"]
if is_flush:
self.flush(client, collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, metric_type="COSINE")
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
vectors = cf.gen_vectors(nq, dim, vector_data_type)
self.search(client, collection_name,
data=vectors[:nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=default_search_exp,
output_fields=[ct.default_int64_field_name,
ct.default_float_field_name],
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": nq,
"ids": insert_ids,
"pk_name": ct.default_int64_field_name,
"limit": default_limit,
"metric": "COSINE",
"output_fields": [ct.default_int64_field_name,
ct.default_float_field_name]})
# Verify that all returned float values equal the default_value (10.0)
# since all inserted values were None
res = self.search(client, collection_name,
data=vectors[:1],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=default_search_exp,
output_fields=[ct.default_float_field_name])[0]
for hit in res[0]:
assert hit.get(ct.default_float_field_name) == 10.0, \
f"Expected default_value 10.0 but got {hit.get(ct.default_float_field_name)}"
@pytest.mark.tags(CaseLabel.L1)
def test_search_collection_with_non_default_data_after_release_load(self):
"""
target: verify search works after release+load on collection with nullable varchar and default float
method: 1. create collection with default_value float + nullable varchar
2. insert, flush, index, load release load again
3. search and verify results
expected: search returns correct results after re-load, distances in COSINE order
"""
nq = 200
nb = ct.default_nb
dim = 64
auto_id = True
null_data_percent = 0.5
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True, auto_id=auto_id)
schema.add_field(ct.default_float_field_name, DataType.FLOAT, default_value=np.float32(10.0))
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535, nullable=True)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=dim)
self.create_collection(client, collection_name, schema=schema)
data = cf.gen_default_rows_data(nb=nb, dim=dim, auto_id=auto_id, with_json=True,
nullable_fields={ct.default_string_field_name: null_data_percent})
insert_res, _ = self.insert(client, collection_name, data=data)
insert_ids = insert_res["ids"]
self.flush(client, collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, metric_type="COSINE")
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
# release and reload
self.release_collection(client, collection_name)
self.load_collection(client, collection_name)
log.info("test_search_collection_with_non_default_data_after_release_load: searching after load")
vectors = cf.gen_vectors(nq, dim)
self.search(client, collection_name,
data=vectors[:nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=default_search_exp,
output_fields=[ct.default_float_field_name, ct.default_string_field_name],
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": nq,
"ids": insert_ids,
"pk_name": ct.default_int64_field_name,
"limit": default_limit,
"metric": "COSINE",
"output_fields": [ct.default_float_field_name,
ct.default_string_field_name]})
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.tags(CaseLabel.GPU)
@pytest.mark.parametrize("varchar_scalar_index", ["TRIE", "INVERTED", "BITMAP"])
@pytest.mark.parametrize("numeric_scalar_index", ["STL_SORT", "INVERTED"])
def test_search_after_different_index_with_params_none_default_data(self, varchar_scalar_index,
numeric_scalar_index):
"""
target: verify search works with nullable varchar + default_value float across different scalar indexes
method: 1. create collection with nullable varchar + default_value float
2. create various scalar indexes (TRIE/INVERTED/BITMAP for varchar, STL_SORT/INVERTED for numeric)
3. search and verify results
expected: search returns correct results with distances in COSINE order
"""
null_data_percent = 0.5
nullable_fields = {ct.default_string_field_name: null_data_percent}
default_value_fields = {ct.default_float_field_name: np.float32(10.0)}
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
default_schema = cf.gen_collection_schema_all_datatype(auto_id=False, dim=default_dim,
enable_dynamic_field=False,
nullable_fields=nullable_fields,
default_value_fields=default_value_fields)
self.create_collection(client, collection_name, schema=default_schema)
data = cf.gen_default_rows_data_all_data_type(nb=3000, dim=default_dim)
for field_key, percent in nullable_fields.items():
null_number = int(3000 * percent)
for row in data[-null_number:]:
if field_key in row:
row[field_key] = None
insert_res, _ = self.insert(client, collection_name, data=data)
insert_ids = insert_res["ids"]
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, index_type="HNSW",
metric_type="COSINE", params=cf.get_index_params_params("HNSW"))
self.create_index(client, collection_name, index_params=idx)
scalar_idx = self.prepare_index_params(client)[0]
scalar_idx.add_index(field_name=ct.default_string_field_name, index_type=varchar_scalar_index)
self.create_index(client, collection_name, index_params=scalar_idx)
scalar_idx2 = self.prepare_index_params(client)[0]
scalar_idx2.add_index(field_name=ct.default_float_field_name, index_type=numeric_scalar_index)
self.create_index(client, collection_name, index_params=scalar_idx2)
self.load_collection(client, collection_name)
limit = ct.default_limit
search_params = {}
vectors = cf.gen_vectors(default_nq, default_dim)
self.search(client, collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=search_params,
limit=limit,
filter=default_search_exp,
output_fields=[ct.default_string_field_name, ct.default_float_field_name],
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": default_nq,
"ids": insert_ids,
"limit": limit,
"metric": "COSINE",
"pk_name": ct.default_int64_field_name,
"output_fields": [ct.default_string_field_name,
ct.default_float_field_name]})
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("batch_size", [200, 600])
def test_search_iterator_with_none_data(self, batch_size):
"""
target: verify search iterator works on collection with nullable varchar field
method: 1. create collection with nullable varchar, insert data
2. run search iterator with L2 metric
3. check batch_size via check_search_iterator
expected: iterator returns batches of correct size with unique PKs
"""
dim = 64
null_data_percent = 0.5
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True)
schema.add_field(ct.default_float_field_name, DataType.FLOAT)
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535, nullable=True)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=dim)
self.create_collection(client, collection_name, schema=schema)
data = cf.gen_default_rows_data(nb=default_nb, dim=dim, with_json=True,
nullable_fields={ct.default_string_field_name: null_data_percent})
self.insert(client, collection_name, data=data)
self.flush(client, collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, metric_type="L2")
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
search_params = {"metric_type": "L2"}
vectors = cf.gen_vectors(1, dim, vector_data_type=DataType.FLOAT_VECTOR)
self.search_iterator(client, collection_name, data=vectors[:1],
batch_size=batch_size,
anns_field=ct.default_float_vec_field_name,
search_params=search_params,
check_task=CheckTasks.check_search_iterator,
check_items={"batch_size": batch_size})
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("is_flush", [False, True])
@pytest.mark.parametrize("enable_dynamic_field", [True, False])
def test_search_none_data_partial_load(self, is_flush, enable_dynamic_field):
"""
target: verify search works after partial load on collection with nullable float field
method: 1. create collection with nullable float, insert, load
2. release, then partial load (only PK + vector + float if not dynamic)
3. search and verify results
expected: search returns correct results with distances in COSINE order
"""
null_data_percent = 0.5
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
schema = self.create_schema(client, enable_dynamic_field=enable_dynamic_field)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True)
schema.add_field(ct.default_float_field_name, DataType.FLOAT, nullable=True)
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
self.create_collection(client, collection_name, schema=schema)
data = cf.gen_default_rows_data(nb=default_nb, dim=default_dim, with_json=True,
nullable_fields={ct.default_float_field_name: null_data_percent})
insert_res, _ = self.insert(client, collection_name, data=data)
insert_ids = insert_res["ids"]
if is_flush:
self.flush(client, collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, metric_type="COSINE")
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
# release and partial load
self.release_collection(client, collection_name)
loaded_fields = [ct.default_int64_field_name, ct.default_float_vec_field_name]
if not enable_dynamic_field:
loaded_fields.append(ct.default_float_field_name)
self.load_collection(client, collection_name, load_fields=loaded_fields)
vectors = cf.gen_vectors(default_nq, default_dim)
output_fields = [ct.default_int64_field_name, ct.default_float_field_name]
self.search(client, collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=default_search_exp,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": default_nq,
"ids": insert_ids,
"pk_name": ct.default_int64_field_name,
"limit": default_limit,
"metric": "COSINE",
"output_fields": output_fields})
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.skip(reason="issue #37547")
@pytest.mark.parametrize("is_flush", [False, True])
def test_search_none_data_expr_cache(self, is_flush):
"""
target: verify expression cache invalidation when collection is recreated with different nullable field type
method: 1. create collection with nullable FLOAT field, search with "float == 0"
2. drop collection
3. recreate same name with float field as VARCHAR (nullable), insert None
4. search with same expr "float == 0" should error (VarChar vs Int64)
expected: first search succeeds with limit=1; second search returns type mismatch error
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True)
schema.add_field(ct.default_float_field_name, DataType.FLOAT, nullable=True)
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
self.create_collection(client, collection_name, schema=schema)
data = cf.gen_default_rows_data(nb=default_nb, dim=default_dim, with_json=True,
nullable_fields={ct.default_float_field_name: 0.5})
insert_res, _ = self.insert(client, collection_name, data=data)
insert_ids = insert_res["ids"]
if is_flush:
self.flush(client, collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, metric_type="COSINE")
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
vectors = cf.gen_vectors(default_nq, default_dim)
search_exp = f"{ct.default_float_field_name} == 0"
output_fields = [ct.default_int64_field_name, ct.default_float_field_name]
self.search(client, collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=search_exp,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"enable_milvus_client_api": True,
"nq": default_nq,
"ids": insert_ids,
"limit": 1,
"metric": "COSINE",
"pk_name": ct.default_int64_field_name,
"output_fields": output_fields})
# drop and recreate with varchar type for float field
self.drop_collection(client, collection_name)
schema2 = self.create_schema(client, enable_dynamic_field=False)[0]
schema2.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True)
schema2.add_field(ct.default_float_field_name, DataType.VARCHAR, max_length=65535, nullable=True)
schema2.add_field(ct.default_json_field_name, DataType.JSON)
schema2.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
self.create_collection(client, collection_name, schema=schema2)
rows = cf.gen_row_data_by_schema(nb=default_nb, schema=schema2)
for row in rows:
row[ct.default_float_field_name] = None
self.insert(client, collection_name, data=rows)
idx2 = self.prepare_index_params(client)[0]
idx2.add_index(field_name=ct.default_float_vec_field_name, metric_type="COSINE")
self.create_index(client, collection_name, index_params=idx2)
self.load_collection(client, collection_name)
self.flush(client, collection_name)
self.search(client, collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=search_exp,
output_fields=output_fields,
check_task=CheckTasks.err_res,
check_items={"err_code": 1100,
"err_msg": "failed to create query plan: cannot parse expression: float == 0, "
"error: comparisons between VarChar and Int64 are not supported: "
"invalid parameter"})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,793 @@
from pymilvus import DataType
from utils.util_pymilvus import *
from common.common_type import CaseLabel, CheckTasks
from common import common_type as ct
from common import common_func as cf
from utils.util_log import test_log as log
from base.client_v2_base import TestMilvusClientV2Base
import pytest
default_nb = ct.default_nb
default_nq = ct.default_nq
default_dim = ct.default_dim
default_limit = ct.default_limit
default_search_string_exp = "varchar >= \"0\""
default_search_mix_exp = "int64 >= 0 && varchar >= \"0\""
default_invalid_string_exp = "varchar >= 0"
prefix_expr = 'varchar like "0%"'
default_search_field = ct.default_float_vec_field_name
default_search_params = ct.default_search_params
default_int64_field_name = ct.default_int64_field_name
default_float_field_name = ct.default_float_field_name
default_string_field_name = ct.default_string_field_name
@pytest.mark.xdist_group("TestSearchStringAutoId")
@pytest.mark.tags(CaseLabel.GPU)
class TestSearchStringAutoId(TestMilvusClientV2Base):
"""Shared collection with auto_id=True
Schema: int64(PK, auto_id=True), float, varchar(65535), json, float_vector(128), dynamic=False
Data: 3000 rows, varchar overridden with str(i) for predictable prefix/comparison expressions
Index: COSINE on float_vector
"""
shared_alias = "TestSearchStringAutoId"
def setup_class(self):
super().setup_class(self)
self.collection_name = "TestSearchStringAutoId" + cf.gen_unique_str("_")
@pytest.fixture(scope="class", autouse=True)
def prepare_collection(self, request):
client = self._client(alias=self.shared_alias)
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True, auto_id=True)
schema.add_field(ct.default_float_field_name, DataType.FLOAT)
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
self.create_collection(client, self.collection_name, schema=schema, force_teardown=False)
data = cf.gen_row_data_by_schema(nb=3000, schema=schema)
# Override varchar with str(i) so prefix/comparison expressions work predictably
# (gen_row_data_by_schema generates random strings without predictable ordering)
for i in range(len(data)):
data[i][ct.default_string_field_name] = str(i)
self.insert(client, self.collection_name, data=data)
self.flush(client, self.collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, metric_type="COSINE")
self.create_index(client, self.collection_name, index_params=idx)
self.load_collection(client, self.collection_name)
def teardown():
self.drop_collection(self._client(alias=self.shared_alias), self.collection_name)
request.addfinalizer(teardown)
@pytest.mark.tags(CaseLabel.L2)
def test_search_string_field_not_primary(self):
"""
target: verify search with string equality filter on non-primary varchar field
method: 1. query to get a valid varchar value
2. search with filter varchar == 'value' on shared collection
3. check nq, limit, distance order via check_task
4. manually assert returned varchar matches search string
expected: exactly 1 result with matching varchar value, distances in COSINE order
"""
client = self._client(alias=self.shared_alias)
# query to get a valid string value from the collection
query_res, _ = self.query(client, self.collection_name, filter="int64 >= 0",
output_fields=[default_string_field_name], limit=10)
search_str = query_res[1][default_string_field_name]
search_exp = f"{default_string_field_name} == '{search_str}'"
log.info("test_search_string_field_not_primary: searching collection %s" % self.collection_name)
log.info("search expr: %s" % search_exp)
output_fields = [default_string_field_name, default_float_field_name]
vectors = cf.gen_vectors(default_nq, default_dim)
res, _ = self.search(client, self.collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=search_exp,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"pk_name": default_int64_field_name,
"limit": 1,
"metric": "COSINE",
"enable_milvus_client_api": True})
assert res[0][0]["entity"][default_string_field_name] == search_str
@pytest.mark.tags(CaseLabel.L2)
def test_search_string_mix_expr(self):
"""
target: verify search with mixed int64 and varchar comparison filter
method: 1. search with filter "int64 >= 0 && varchar >= '0'" on shared collection
2. check nq, limit, distance order via check_task
3. manually assert all results satisfy both filter conditions
expected: all results have int64 >= 0 and varchar >= "0", distances in COSINE order
"""
client = self._client(alias=self.shared_alias)
log.info("test_search_string_mix_expr: searching collection %s" %
self.collection_name)
output_fields = [default_string_field_name, default_float_field_name]
vectors = cf.gen_vectors(default_nq, default_dim)
res, _ = self.search(client, self.collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=default_search_mix_exp,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"pk_name": default_int64_field_name,
"limit": default_limit,
"metric": "COSINE",
"enable_milvus_client_api": True})
# manually verify filter effectiveness
for hits in res:
for hit in hits:
assert hit.entity.get(default_string_field_name) >= "0"
assert hit.entity.get(default_int64_field_name) >= 0
@pytest.mark.tags(CaseLabel.L2)
def test_search_string_with_invalid_expr(self):
"""
target: verify search with invalid string expression raises error
method: 1. search with filter "varchar >= 0" (int comparison on varchar)
2. check error response
expected: error 1100 with "cannot parse expression" message
"""
client = self._client(alias=self.shared_alias)
log.info("test_search_string_with_invalid_expr: searching collection %s" %
self.collection_name)
vectors = cf.gen_vectors(default_nq, default_dim)
self.search(client, self.collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=default_invalid_string_exp,
check_task=CheckTasks.err_res,
check_items={"err_code": 1100,
"err_msg": "failed to create query plan: cannot "
"parse expression: varchar >= 0"})
@pytest.mark.tags(CaseLabel.L2)
def test_search_string_field_not_primary_prefix(self):
"""
target: verify search with prefix (LIKE) filter on non-primary varchar field
method: 1. search with filter 'varchar like "0%"' on shared collection
2. check nq, limit, distance order via check_task
3. manually assert all returned varchar values start with "0"
expected: results have varchar starting with "0", distances in COSINE order
"""
client = self._client(alias=self.shared_alias)
log.info("test_search_string_field_not_primary_prefix: searching collection %s" %
self.collection_name)
output_fields = [default_float_field_name, default_string_field_name]
vectors = cf.gen_vectors(default_nq, default_dim)
res, _ = self.search(client, self.collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=prefix_expr,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": 1,
"metric": "COSINE",
"pk_name": default_int64_field_name,
"enable_milvus_client_api": True})
# manually verify prefix filter effectiveness
for hits in res:
for hit in hits:
assert str(hit.entity.get(default_string_field_name, "")).startswith("0")
@pytest.mark.tags(CaseLabel.L2)
def test_search_string_field_not_primary_is_empty(self):
"""
target: verify search with empty-string comparison filter on varchar field
method: 1. search with filter 'varchar >= ""' on shared collection
2. check nq, limit, distance order via check_task
expected: all rows match (every varchar >= ""), distances in COSINE order
"""
client = self._client(alias=self.shared_alias)
search_string_exp = "varchar >= \"\""
log.info("test_search_string_field_not_primary_is_empty: searching collection %s" %
self.collection_name)
output_fields = [default_string_field_name, default_float_field_name]
vectors = cf.gen_vectors(default_nq, default_dim)
res, _ = self.search(client, self.collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=search_string_exp,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"pk_name": default_int64_field_name,
"limit": default_limit,
"metric": "COSINE",
"enable_milvus_client_api": True})
# manually verify filter effectiveness
for hits in res:
for hit in hits:
assert hit.entity.get(default_string_field_name, "") >= ""
@pytest.mark.xdist_group("TestSearchStringVarcharPK")
@pytest.mark.tags(CaseLabel.GPU)
class TestSearchStringVarcharPK(TestMilvusClientV2Base):
"""Shared collection with varchar PK
Schema: varchar(65535, PK), int64, float, json, float_vector(128), dynamic=False
Data: 3000 rows
Index: COSINE on float_vector, TRIE on varchar
"""
shared_alias = "TestSearchStringVarcharPK"
def setup_class(self):
super().setup_class(self)
self.collection_name = "TestSearchStringVarcharPK" + cf.gen_unique_str("_")
@pytest.fixture(scope="class", autouse=True)
def prepare_collection(self, request):
client = self._client(alias=self.shared_alias)
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535, is_primary=True)
schema.add_field(ct.default_int64_field_name, DataType.INT64)
schema.add_field(ct.default_float_field_name, DataType.FLOAT)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
self.create_collection(client, self.collection_name, schema=schema, force_teardown=False)
data = cf.gen_row_data_by_schema(nb=3000, schema=schema)
self.insert(client, self.collection_name, data=data)
self.flush(client, self.collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, metric_type="COSINE")
idx.add_index(field_name=ct.default_string_field_name, index_type="Trie")
self.create_index(client, self.collection_name, index_params=idx)
self.load_collection(client, self.collection_name)
def teardown():
self.drop_collection(self._client(alias=self.shared_alias), self.collection_name)
request.addfinalizer(teardown)
@pytest.mark.tags(CaseLabel.L2)
def test_search_string_field_is_primary_true(self):
"""
target: verify search with string equality filter when varchar is primary key
method: 1. query to get a valid varchar PK value
2. search with filter varchar == 'value' on shared varchar-PK collection
3. check nq, limit, distance order via check_task
4. manually assert returned varchar matches search string
expected: exactly 1 result with matching varchar PK, distances in COSINE order
"""
client = self._client(alias=self.shared_alias)
# query to get a valid string value from the collection
query_res, _ = self.query(client, self.collection_name, filter="int64 >= 0",
output_fields=[default_string_field_name], limit=10)
search_str = query_res[1][default_string_field_name]
search_exp = f"{default_string_field_name} == '{search_str}'"
log.info("test_search_string_field_is_primary_true: searching collection %s" % self.collection_name)
log.info("search expr: %s" % search_exp)
output_fields = [default_string_field_name, default_float_field_name]
vectors = cf.gen_vectors(default_nq, default_dim)
res, _ = self.search(client, self.collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=search_exp,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"pk_name": ct.default_string_field_name,
"limit": 1,
"metric": "COSINE",
"enable_milvus_client_api": True})
assert res[0][0]["entity"][default_string_field_name] == search_str
@pytest.mark.tags(CaseLabel.L2)
def test_search_string_field_index(self):
"""
target: verify search with prefix (LIKE) filter on varchar PK with Trie index
method: 1. search with filter 'varchar like "0%"' on varchar-PK collection with Trie index
2. check nq, limit, distance order via check_task
expected: results match prefix filter, distances in COSINE order
"""
client = self._client(alias=self.shared_alias)
log.info("test_search_string_field_index: searching collection %s" %
self.collection_name)
output_fields = [default_float_field_name, default_string_field_name]
vectors = cf.gen_vectors(default_nq, default_dim)
res, _ = self.search(client, self.collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=prefix_expr,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": 1,
"metric": "COSINE",
"pk_name": ct.default_string_field_name,
"enable_milvus_client_api": True})
# manually verify prefix filter effectiveness
for hits in res:
for hit in hits:
assert str(hit.entity.get(default_string_field_name, "")).startswith("0")
@pytest.mark.tags(CaseLabel.L2)
def test_search_string_field_is_primary_insert_empty(self):
"""
target: verify search with empty-string comparison filter on varchar PK
method: 1. search with filter 'varchar >= ""' (matches all rows) on varchar-PK collection
2. check nq, limit, distance order via check_task
expected: results returned (all rows match), distances in COSINE order
"""
client = self._client(alias=self.shared_alias)
search_string_exp = "varchar >= \"\""
limit = 1
log.info("test_search_string_field_is_primary_insert_empty: searching collection %s" %
self.collection_name)
output_fields = [default_string_field_name, default_float_field_name]
vectors = cf.gen_vectors(default_nq, default_dim)
res, _ = self.search(client, self.collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=limit,
filter=search_string_exp,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": limit,
"metric": "COSINE",
"enable_milvus_client_api": True,
"pk_name": ct.default_string_field_name})
# manually verify filter effectiveness
for hits in res:
for hit in hits:
assert hit.entity.get(default_string_field_name, "") >= ""
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("expression", cf.gen_normal_string_expressions([ct.default_string_field_name]))
def test_search_with_different_string_expr(self, expression):
"""
target: verify search with various string expressions returns only matching rows
method: 1. query all rows from varchar-PK collection
2. evaluate expression locally to get expected matching PKs
3. search with the expression
4. assert all returned PKs are a subset of expected matching PKs
expected: all returned results satisfy the string expression
"""
client = self._client(alias=self.shared_alias)
nb = 3000
# query to get all data for filter evaluation (varchar is PK, use varchar > "" to match all)
query_res, _ = self.query(client, self.collection_name, filter='varchar > ""',
output_fields=[ct.default_int64_field_name, ct.default_string_field_name],
limit=nb)
# filter result with expression in collection
filter_ids = []
expression_eval = expression.replace("&&", "and").replace("||", "or")
for item in query_res:
int64 = item[ct.default_int64_field_name]
varchar = item[ct.default_string_field_name]
if not expression_eval or eval(expression_eval):
filter_ids.append(item[ct.default_string_field_name])
# search with expression (AUTOINDEX/HNSW may not return all matches, use subset check)
log.info("test_search_with_expression: searching with expression: %s" % expression)
vectors = cf.gen_vectors(default_nq, default_dim)
search_res, _ = self.search(client, self.collection_name,
data=vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=nb,
filter=expression)
filter_ids_set = set(filter_ids)
for hits in search_res:
ids = [hit[ct.default_string_field_name] for hit in hits]
assert set(ids).issubset(filter_ids_set)
@pytest.mark.xdist_group("TestSearchStringBinary")
@pytest.mark.tags(CaseLabel.GPU)
class TestSearchStringBinary(TestMilvusClientV2Base):
"""Shared collection with binary vectors
Schema: int64(PK, auto_id=True), float, varchar(65535), binary_vector(128), dynamic=False
Data: 3000 rows with binary vectors, varchar=str(i), float=i*1.0
Index: BIN_IVF_FLAT/JACCARD
"""
shared_alias = "TestSearchStringBinary"
def setup_class(self):
super().setup_class(self)
self.collection_name = "TestSearchStringBinary" + cf.gen_unique_str("_")
@pytest.fixture(scope="class", autouse=True)
def prepare_collection(self, request):
client = self._client(alias=self.shared_alias)
dim = 128
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True, auto_id=True)
schema.add_field(ct.default_float_field_name, DataType.FLOAT)
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535)
schema.add_field(ct.default_binary_vec_field_name, DataType.BINARY_VECTOR, dim=dim)
self.create_collection(client, self.collection_name, schema=schema, force_teardown=False)
nb = 3000
data = cf.gen_row_data_by_schema(nb=nb, schema=schema)
# Override varchar and float with deterministic values for predictable filter expressions
_, binary_vectors = cf.gen_binary_vectors(nb, dim)
for i in range(nb):
data[i][ct.default_float_field_name] = i * 1.0
data[i][ct.default_string_field_name] = str(i)
data[i][ct.default_binary_vec_field_name] = binary_vectors[i]
self.insert(client, self.collection_name, data=data)
self.flush(client, self.collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_binary_vec_field_name,
index_type="BIN_IVF_FLAT", metric_type="JACCARD",
params={"nlist": 128})
self.create_index(client, self.collection_name, index_params=idx)
self.load_collection(client, self.collection_name)
def teardown():
self.drop_collection(self._client(alias=self.shared_alias), self.collection_name)
request.addfinalizer(teardown)
@pytest.mark.tags(CaseLabel.L2)
def test_search_string_field_is_primary_binary(self):
"""
target: verify search with string comparison filter on binary vector collection
method: 1. search with filter 'varchar >= "0"' on binary vector collection
2. check nq, limit, distance order via check_task
expected: results match filter, distances in JACCARD ascending order
"""
client = self._client(alias=self.shared_alias)
dim = 128
_, search_binary_vectors = cf.gen_binary_vectors(default_nq, dim)
search_params = {"metric_type": "JACCARD", "params": {"nprobe": 10}}
output_fields = [default_string_field_name]
res, _ = self.search(client, self.collection_name,
data=search_binary_vectors[:default_nq],
anns_field=ct.default_binary_vec_field_name,
search_params=search_params,
limit=default_limit,
filter=default_search_string_exp,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit,
"metric": "JACCARD",
"pk_name": default_int64_field_name,
"enable_milvus_client_api": True})
# manually verify filter effectiveness
for hits in res:
for hit in hits:
assert hit.entity.get(default_string_field_name) >= "0"
@pytest.mark.tags(CaseLabel.L2)
def test_search_string_field_binary(self):
"""
target: verify search with string comparison filter on binary vector collection (no output fields)
method: 1. search with filter 'varchar >= "0"' on binary vector collection
2. check nq, limit, distance order via check_task
expected: results match filter, distances in JACCARD ascending order
"""
client = self._client(alias=self.shared_alias)
dim = 128
_, search_binary_vectors = cf.gen_binary_vectors(default_nq, dim)
search_params = {"metric_type": "JACCARD", "params": {"nprobe": 10}}
res, _ = self.search(client, self.collection_name,
data=search_binary_vectors[:default_nq],
anns_field=ct.default_binary_vec_field_name,
search_params=search_params,
limit=default_limit,
filter=default_search_string_exp,
output_fields=[default_string_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit,
"metric": "JACCARD",
"pk_name": default_int64_field_name,
"enable_milvus_client_api": True})
# manually verify filter effectiveness
for hits in res:
for hit in hits:
assert hit.entity.get(default_string_field_name) >= "0"
@pytest.mark.tags(CaseLabel.L2)
def test_search_mix_expr_with_binary(self):
"""
target: verify search with mixed int64+varchar filter on binary vector collection
method: 1. search with filter "int64 >= 0 && varchar >= '0'" on binary collection
2. check nq, limit, distance order via check_task
3. manually assert all results satisfy both filter conditions
expected: all results have int64 >= 0 and varchar >= "0", distances in JACCARD order
"""
client = self._client(alias=self.shared_alias)
dim = 128
log.info("test_search_mix_expr_with_binary: searching collection %s" %
self.collection_name)
_, search_binary_vectors = cf.gen_binary_vectors(default_nq, dim)
search_params = {"metric_type": "JACCARD", "params": {"nprobe": 10}}
output_fields = [default_string_field_name, default_float_field_name]
res, _ = self.search(client, self.collection_name,
data=search_binary_vectors[:default_nq],
anns_field=ct.default_binary_vec_field_name,
search_params=search_params,
limit=default_limit,
filter=default_search_mix_exp,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"pk_name": default_int64_field_name,
"limit": default_limit,
"metric": "JACCARD",
"enable_milvus_client_api": True})
# manually verify filter effectiveness
for hits in res:
for hit in hits:
assert hit.entity.get(default_string_field_name) >= "0"
assert hit.entity.get(default_int64_field_name) >= 0
class TestSearchStringIndependent(TestMilvusClientV2Base):
"""Independent tests for string search scenarios requiring unique schemas
(multi-language, multi-vector, range search, cross-field comparison)
"""
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("language", ["en", "zh", "de"])
def test_search_string_different_language(self, language):
"""
target: verify search with string equality filter using different language data
method: 1. create collection with multi-language string data
2. query to get a valid varchar value
3. search with filter varchar == 'value'
4. manually assert returned varchar matches search string
expected: exactly 1 result with matching varchar, distances in COSINE order
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
nb = ct.default_nb
dim = 64
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(ct.default_int64_field_name, DataType.INT64, is_primary=True, auto_id=True)
schema.add_field(ct.default_float_field_name, DataType.FLOAT)
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=dim)
self.create_collection(client, collection_name, schema=schema)
data = cf.gen_default_rows_data(nb=nb, dim=dim, auto_id=True, with_json=False, language=language)
self.insert(client, collection_name, data=data)
self.flush(client, collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, metric_type="COSINE")
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
# query to get a valid string value from the collection
query_res, _ = self.query(client, collection_name, filter='varchar > ""',
output_fields=[default_string_field_name], limit=10)
search_str = query_res[0][default_string_field_name]
search_exp = f"{default_string_field_name} == '{search_str}'"
log.info("test_search_string_different_language: searching with language=%s" % language)
search_vectors = cf.gen_vectors(default_nq, dim)
output_fields = [default_string_field_name, default_float_field_name]
res, _ = self.search(client, collection_name,
data=search_vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=search_exp,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": 1,
"metric": "COSINE",
"pk_name": default_int64_field_name,
"enable_milvus_client_api": True})
assert res[0][0]["entity"][default_string_field_name] == search_str
@pytest.mark.tags(CaseLabel.L2)
def test_search_string_field_is_primary_true_multi_vector_fields(self):
"""
target: verify search with string filter across multiple vector fields when varchar is PK
method: 1. create collection with varchar PK and 3 float vector fields
2. search each vector field with filter 'varchar >= "0"'
3. check nq, limit, returned IDs via check_task
expected: search succeeds on all 3 vector fields, returned IDs are valid, distances in COSINE order
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
dim = 64
multiple_vector_field_1 = cf.gen_unique_str("multiple_vector")
multiple_vector_field_2 = cf.gen_unique_str("multiple_vector")
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535, is_primary=True)
schema.add_field(ct.default_int64_field_name, DataType.INT64)
schema.add_field(ct.default_float_field_name, DataType.FLOAT)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=dim)
schema.add_field(multiple_vector_field_1, DataType.FLOAT_VECTOR, dim=dim)
schema.add_field(multiple_vector_field_2, DataType.FLOAT_VECTOR, dim=dim)
self.create_collection(client, collection_name, schema=schema)
data = cf.gen_row_data_by_schema(nb=default_nb, schema=schema)
insert_res, _ = self.insert(client, collection_name, data=data)
insert_ids = insert_res["ids"]
self.flush(client, collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, metric_type="COSINE")
idx.add_index(field_name=multiple_vector_field_1, metric_type="COSINE")
idx.add_index(field_name=multiple_vector_field_2, metric_type="COSINE")
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
log.info("test_search_string_field_is_primary_true_multi_vector_fields: searching collection %s" %
collection_name)
search_vectors = cf.gen_vectors(default_nq, dim)
output_fields = [default_string_field_name, default_float_field_name]
vector_list = [ct.default_float_vec_field_name, multiple_vector_field_1, multiple_vector_field_2]
for search_field in vector_list:
res, _ = self.search(client, collection_name,
data=search_vectors[:default_nq],
anns_field=search_field,
search_params=default_search_params,
limit=default_limit,
filter=default_search_string_exp,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"ids": insert_ids,
"pk_name": ct.default_string_field_name,
"limit": default_limit,
"metric": "COSINE",
"enable_milvus_client_api": True})
# manually verify filter effectiveness
for hits in res:
for hit in hits:
assert hit.entity.get(default_string_field_name) >= "0"
@pytest.mark.tags(CaseLabel.L2)
def test_range_search_string_field_is_primary_true(self):
"""
target: verify range search with string filter across multiple vector fields when varchar is PK
method: 1. create collection with varchar PK, dynamic field, and 3 float vector fields (L2)
2. range search each vector field with filter 'varchar >= "0"'
3. check nq, limit, returned IDs via check_task
expected: range search succeeds on all 3 vector fields, returned IDs are valid, distances in L2 order
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
dim = 64
multiple_vector_field_1 = cf.gen_unique_str("multiple_vector")
multiple_vector_field_2 = cf.gen_unique_str("multiple_vector")
schema = self.create_schema(client, enable_dynamic_field=True)[0]
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535, is_primary=True)
schema.add_field(ct.default_int64_field_name, DataType.INT64)
schema.add_field(ct.default_float_field_name, DataType.FLOAT)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=dim)
schema.add_field(multiple_vector_field_1, DataType.FLOAT_VECTOR, dim=dim)
schema.add_field(multiple_vector_field_2, DataType.FLOAT_VECTOR, dim=dim)
self.create_collection(client, collection_name, schema=schema)
data = cf.gen_row_data_by_schema(nb=default_nb, schema=schema)
insert_res, _ = self.insert(client, collection_name, data=data)
insert_ids = insert_res["ids"]
self.flush(client, collection_name)
# Create index with L2 metric
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name, metric_type="L2")
idx.add_index(field_name=multiple_vector_field_1, metric_type="L2")
idx.add_index(field_name=multiple_vector_field_2, metric_type="L2")
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
log.info("test_range_search_string_field_is_primary_true: searching collection %s" %
collection_name)
range_search_params = {"metric_type": "L2",
"params": {"radius": 1000, "range_filter": 0}}
search_vectors = cf.gen_vectors(default_nq, dim)
output_fields = [default_string_field_name, default_float_field_name]
vector_list = [ct.default_float_vec_field_name, multiple_vector_field_1, multiple_vector_field_2]
for search_field in vector_list:
res, _ = self.search(client, collection_name,
data=search_vectors[:default_nq],
anns_field=search_field,
search_params=range_search_params,
limit=default_limit,
filter=default_search_string_exp,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"ids": insert_ids,
"limit": default_limit,
"metric": "L2",
"pk_name": ct.default_string_field_name,
"enable_milvus_client_api": True})
# manually verify filter effectiveness
for hits in res:
for hit in hits:
assert hit.entity.get(default_string_field_name) >= "0"
@pytest.mark.tags(CaseLabel.L1)
def test_search_all_index_with_compare_expr(self):
"""
target: verify search with cross-field comparison filter (float >= int64) on varchar-PK collection
method: 1. create collection with varchar PK, Trie index on varchar, IVF_SQ8 on vector
2. verify Trie index exists
3. search with filter 'float >= int64' and output scalar fields
4. manually verify filter effectiveness on returned results
expected: all results satisfy float >= int64, distances in COSINE order
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(ct.default_string_field_name, DataType.VARCHAR, max_length=65535, is_primary=True)
schema.add_field(ct.default_int64_field_name, DataType.INT64)
schema.add_field(ct.default_float_field_name, DataType.FLOAT)
schema.add_field(ct.default_json_field_name, DataType.JSON)
schema.add_field(ct.default_float_vec_field_name, DataType.FLOAT_VECTOR, dim=default_dim)
self.create_collection(client, collection_name, schema=schema)
data = cf.gen_row_data_by_schema(nb=default_nb, schema=schema)
insert_res, _ = self.insert(client, collection_name, data=data)
insert_ids = insert_res["ids"]
self.flush(client, collection_name)
# create index
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_float_vec_field_name,
index_type="IVF_SQ8", metric_type="COSINE",
params={"nlist": 64})
idx.add_index(field_name=ct.default_string_field_name, index_type="Trie")
self.create_index(client, collection_name, index_params=idx)
# verify index exists
indexes, _ = self.list_indexes(client, collection_name)
assert ct.default_string_field_name in indexes
self.release_collection(client, collection_name)
self.load_collection(client, collection_name)
# search with compare expr
expr = 'float >= int64'
search_vectors = cf.gen_vectors(default_nq, default_dim)
output_fields = [default_int64_field_name,
default_float_field_name, default_string_field_name]
res, _ = self.search(client, collection_name,
data=search_vectors[:default_nq],
anns_field=default_search_field,
search_params=default_search_params,
limit=default_limit,
filter=expr,
output_fields=output_fields,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"ids": insert_ids,
"limit": default_limit,
"metric": "COSINE",
"pk_name": ct.default_string_field_name,
"enable_milvus_client_api": True})
# manually verify cross-field comparison filter
for hits in res:
for hit in hits:
assert hit.entity.get(default_float_field_name) >= hit.entity.get(default_int64_field_name)
@@ -0,0 +1,423 @@
import pandas as pd
import pytest
from base.client_v2_base import TestMilvusClientV2Base
from common import common_func as cf
from common.common_type import CaseLabel, CheckTasks
from faker import Faker
from pymilvus import DataType
from utils.util_log import test_log as log
Faker.seed(19530)
fake_en = Faker("en_US")
fake_zh = Faker("zh_CN")
# patch faker to generate text with specific distribution
cf.patch_faker_text(fake_en, cf.en_vocabularies_distribution)
cf.patch_faker_text(fake_zh, cf.zh_vocabularies_distribution)
pd.set_option("expand_frame_repr", False)
class TestSearchTextMatchIndependent(TestMilvusClientV2Base):
"""Independent tests for text match search with tokenized varchar fields.
Each test creates its own collection because text_match requires specialized schema
(enable_analyzer, enable_match, analyzer_params) that varies by tokenizer config.
Verification approach:
- Build word frequency map from inserted data using cf.analyze_documents
- Search with text_match filter using most common tokens
- Manually assert every returned result contains the matched token(s)
"""
TEXT_FIELDS = ["word", "sentence", "paragraph", "text"]
def _setup_text_match_collection(self, client, tokenizer, enable_inverted_index, enable_partition_key):
"""Helper to create collection, insert faker data, build index, and return analysis artifacts.
Returns: (collection_name, df_split, wf_map, dim)
"""
if tokenizer == "jieba":
language = "zh"
fake = fake_zh
else:
language = "en"
fake = fake_en
analyzer_params = {"tokenizer": tokenizer}
dim = 128
collection_name = cf.gen_collection_name_by_testcase_name()
schema = self.create_schema(client)[0]
schema.add_field("id", DataType.INT64, is_primary=True)
for field_name in self.TEXT_FIELDS:
extra = {}
if field_name == "word" and enable_partition_key:
extra["is_partition_key"] = True
schema.add_field(
field_name,
DataType.VARCHAR,
max_length=65535,
enable_analyzer=True,
enable_match=True,
analyzer_params=analyzer_params,
**extra,
)
schema.add_field("float32_emb", DataType.FLOAT_VECTOR, dim=dim)
schema.add_field("sparse_emb", DataType.SPARSE_FLOAT_VECTOR)
self.create_collection(client, collection_name, schema=schema)
# Generate and insert data
data_size = 5000
float_vectors = cf.gen_vectors(data_size, dim)
sparse_vectors = cf.gen_sparse_vectors(data_size, dim=10000)
data = [
{
"id": i,
"word": fake.word().lower(),
"sentence": fake.sentence().lower(),
"paragraph": fake.paragraph().lower(),
"text": fake.text().lower(),
"float32_emb": float_vectors[i],
"sparse_emb": sparse_vectors[i],
}
for i in range(data_size)
]
self.insert(client, collection_name, data=data)
self.flush(client, collection_name)
# Build indexes
idx = self.prepare_index_params(client)[0]
idx.add_index(
field_name="float32_emb", index_type="HNSW", metric_type="L2", params={"M": 16, "efConstruction": 500}
)
idx.add_index(field_name="sparse_emb", index_type="SPARSE_INVERTED_INDEX", metric_type="IP")
if enable_inverted_index:
idx.add_index(field_name="word", index_type="INVERTED")
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
# Analyze corpus for verification
df = pd.DataFrame(data)
wf_map = {}
for field in self.TEXT_FIELDS:
wf_map[field] = cf.analyze_documents(df[field].tolist(), language=language)
df_split = cf.split_dataframes(df, self.TEXT_FIELDS, language=language)
return collection_name, df_split, wf_map, dim
@pytest.mark.tags(CaseLabel.L0)
@pytest.mark.parametrize("enable_partition_key", [True, False])
@pytest.mark.parametrize("enable_inverted_index", [True, False])
def test_search_with_text_match_filter_normal_en(self, enable_inverted_index, enable_partition_key):
"""
target: verify text_match filter with standard tokenizer on English text across dense+sparse ANN
method: 1. create collection with enable_analyzer+enable_match on 4 varchar fields
2. insert 5000 rows of faker-generated English text
3. for each ANN field (float32_emb/sparse_emb) and each text field:
a. search with single most-common token assert token in every result
b. search with top-10 tokens assert any token in every result
4. verify text_match supports search-by-pk
expected: all results contain matched token(s); search-by-pk works with text_match filter
"""
client = self._client()
collection_name, df_split, wf_map, dim = self._setup_text_match_collection(
client, "standard", enable_inverted_index, enable_partition_key
)
text_fields = self.TEXT_FIELDS
for ann_field in ["float32_emb", "sparse_emb"]:
log.info(f"ann_field {ann_field}")
if ann_field == "float32_emb":
search_data = cf.gen_vectors(1, dim)
search_params = {"metric_type": "L2"}
else:
search_data = cf.gen_sparse_vectors(1, dim=10000)
search_params = {"metric_type": "IP"}
# search with single token per text field
for field in text_fields:
token = wf_map[field].most_common()[0][0]
expr = f"text_match({field}, '{token}')"
manual_result = df_split[df_split.apply(lambda row, t=token, f=field: t in row[f], axis=1)]
log.info(f"expr: {expr}, manual_check_result: {len(manual_result)}")
res_list, _ = self.search(
client,
collection_name,
data=search_data,
anns_field=ann_field,
search_params=search_params,
limit=100,
filter=expr,
output_fields=["id", field],
)
assert len(res_list) >= 1
assert len(res_list[0]) > 0
assert len(res_list[0]) <= len(manual_result)
for res in res_list:
log.info(f"res len {len(res)} res {res}")
assert len(res) >= 1
for r in res:
assert token in r["entity"][field]
# search with multi-token (top 10 most common words) per text field
for field in text_fields:
top_10_tokens = [word for word, _ in wf_map[field].most_common(10)]
string_of_top_10_words = " ".join(top_10_tokens)
expr = f"text_match({field}, '{string_of_top_10_words}')"
log.info(f"expr {expr}")
res_list, _ = self.search(
client,
collection_name,
data=search_data,
anns_field=ann_field,
search_params=search_params,
limit=100,
filter=expr,
output_fields=["id", field],
)
assert len(res_list) >= 1
assert len(res_list[0]) > 0
for res in res_list:
log.info(f"res len {len(res)} res {res}")
assert len(res) >= 1
for r in res:
assert any(token in r["entity"][field] for token in top_10_tokens)
# verify text_match supports search-by-pk
res_list, _ = self.search(
client,
collection_name,
data=None,
ids=[1, 2],
anns_field=ann_field,
search_params=search_params,
limit=100,
filter=expr,
output_fields=["id", field],
check_task=CheckTasks.check_search_results,
check_items={"nq": 2, "limit": 100, "enable_milvus_client_api": True},
)
for res in res_list:
for r in res:
assert any(token in r["entity"][field] for token in top_10_tokens)
@pytest.mark.tags(CaseLabel.L1)
def test_query_with_text_and_phrase_match_filter_template(self):
"""
target: verify text_match and phrase_match query strings support filter templating
method: create a small analyzer-enabled collection and compare literal filters with template filters
expected: template filters return the same IDs as equivalent literal filters
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
dim = 8
schema = self.create_schema(client)[0]
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field(
"text",
DataType.VARCHAR,
max_length=65535,
enable_analyzer=True,
enable_match=True,
analyzer_params={"tokenizer": "standard"},
)
schema.add_field("float32_emb", DataType.FLOAT_VECTOR, dim=dim)
self.create_collection(client, collection_name, schema=schema)
data = [
{"id": 0, "text": "vector database search", "float32_emb": [0.1] * dim},
{"id": 1, "text": "database vector search", "float32_emb": [0.2] * dim},
{"id": 2, "text": "hybrid sparse retrieval", "float32_emb": [0.3] * dim},
]
self.insert(client, collection_name, data=data)
self.flush(client, collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name="float32_emb", index_type="FLAT", metric_type="L2", params={})
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
cases = [
('text_match(text, "vector")', "text_match(text, {query})", {"query": "vector"}),
(
'phrase_match(text, "vector database", 0)',
"phrase_match(text, {query}, 0)",
{"query": "vector database"},
),
]
for literal_expr, template_expr, template_params in cases:
literal_res, _ = self.query(
client,
collection_name,
filter=literal_expr,
output_fields=["id"],
)
template_res, _ = self.query(
client,
collection_name,
filter=template_expr,
filter_params=template_params,
output_fields=["id"],
)
literal_ids = {row["id"] for row in literal_res}
template_ids = {row["id"] for row in template_res}
assert template_ids == literal_ids
@pytest.mark.tags(CaseLabel.L0)
def test_query_with_text_match_fuzzy_filter(self):
"""
target: verify text_match_fuzzy matches terms within an edit distance
method: index terms, then query with a one-edit typo at distance 1 and 0
expected: distance 1 matches the typo'd term; exact text_match and distance 0 do not
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
dim = 8
schema = self.create_schema(client)[0]
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field(
"text",
DataType.VARCHAR,
max_length=65535,
enable_analyzer=True,
enable_match=True,
analyzer_params={"tokenizer": "standard"},
)
schema.add_field("float32_emb", DataType.FLOAT_VECTOR, dim=dim)
self.create_collection(client, collection_name, schema=schema)
data = [
{"id": 0, "text": "allergy season", "float32_emb": [0.1] * dim},
{"id": 1, "text": "allergic reaction", "float32_emb": [0.2] * dim},
{"id": 2, "text": "vector database", "float32_emb": [0.3] * dim},
]
self.insert(client, collection_name, data=data)
self.flush(client, collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name="float32_emb", index_type="FLAT", metric_type="L2", params={})
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
# "alergy" is one edit from "allergy" (id 0) and three from "allergic" (id 1).
fuzzy_res, _ = self.query(
client,
collection_name,
filter='text_match_fuzzy(text, "alergy", max_edit_distance=1)',
output_fields=["id"],
)
assert {row["id"] for row in fuzzy_res} == {0}
# the same typo under exact text_match finds nothing.
exact_res, _ = self.query(
client,
collection_name,
filter='text_match(text, "alergy")',
output_fields=["id"],
)
assert {row["id"] for row in exact_res} == set()
# distance 0 is exact, so the typo still finds nothing.
zero_res, _ = self.query(
client,
collection_name,
filter='text_match_fuzzy(text, "alergy", max_edit_distance=0)',
output_fields=["id"],
)
assert {row["id"] for row in zero_res} == set()
@pytest.mark.tags(CaseLabel.L0)
@pytest.mark.parametrize("enable_partition_key", [True, False])
@pytest.mark.parametrize("enable_inverted_index", [True, False])
@pytest.mark.skip(reason="unstable: jieba tokenization vs Python substring mismatch, see analysis below")
def test_search_with_text_match_filter_normal_zh(self, enable_inverted_index, enable_partition_key):
"""
target: verify text_match filter with jieba tokenizer on Chinese text across dense+sparse ANN
method: 1. create collection with enable_analyzer+enable_match using jieba tokenizer
2. insert 5000 rows of faker-generated Chinese text
3. for each ANN field and each text field:
a. search with single most-common token assert token in every result
b. search with top-10 tokens assert any token in every result
4. verify text_match supports search-by-pk
expected: all results contain matched token(s); search-by-pk works with text_match filter
"""
client = self._client()
collection_name, df_split, wf_map, dim = self._setup_text_match_collection(
client, "jieba", enable_inverted_index, enable_partition_key
)
text_fields = self.TEXT_FIELDS
for ann_field in ["float32_emb", "sparse_emb"]:
log.info(f"ann_field {ann_field}")
if ann_field == "float32_emb":
search_data = cf.gen_vectors(1, dim)
search_params = {"metric_type": "L2"}
else:
search_data = cf.gen_sparse_vectors(1, dim=10000)
search_params = {"metric_type": "IP"}
# search with single token per text field
for field in text_fields:
token = wf_map[field].most_common()[0][0]
expr = f"text_match({field}, '{token}')"
manual_result = df_split[df_split.apply(lambda row, t=token, f=field: t in row[f], axis=1)]
log.info(f"expr: {expr}, manual_check_result: {len(manual_result)}")
res_list, _ = self.search(
client,
collection_name,
data=search_data,
anns_field=ann_field,
search_params=search_params,
limit=100,
filter=expr,
output_fields=["id", field],
)
assert len(res_list) >= 1
assert len(res_list[0]) > 0
assert len(res_list[0]) <= len(manual_result)
for res in res_list:
log.info(f"res len {len(res)} res {res}")
assert len(res) >= 1
for r in res:
assert token in r["entity"][field]
# search with multi-token (top 10 most common words) per text field
for field in text_fields:
top_10_tokens = [word for word, _ in wf_map[field].most_common(10)]
string_of_top_10_words = " ".join(top_10_tokens)
expr = f"text_match({field}, '{string_of_top_10_words}')"
log.info(f"expr {expr}")
res_list, _ = self.search(
client,
collection_name,
data=search_data,
anns_field=ann_field,
search_params=search_params,
limit=100,
filter=expr,
output_fields=["id", field],
)
assert len(res_list) >= 1
assert len(res_list[0]) > 0
for res in res_list:
log.info(f"res len {len(res)} res {res}")
assert len(res) >= 1
for r in res:
assert any(token in r["entity"][field] for token in top_10_tokens)
# verify text_match supports search-by-pk
res_list, _ = self.search(
client,
collection_name,
data=None,
ids=[1, 2],
anns_field=ann_field,
search_params=search_params,
limit=100,
filter=expr,
output_fields=["id", field],
check_task=CheckTasks.check_search_results,
check_items={"nq": 2, "limit": 100, "enable_milvus_client_api": True},
)
for res in res_list:
for r in res:
assert any(token in r["entity"][field] for token in top_10_tokens)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,495 @@
import pytest
from base.client_v2_base import TestMilvusClientV2Base
from common import common_func as cf
from common import common_type as ct
from common.common_type import CaseLabel, CheckTasks
default_nb = ct.default_nb
default_nq = ct.default_nq
default_limit = ct.default_limit
@pytest.mark.xdist_group("TestSparseSearchShared")
@pytest.mark.tags(CaseLabel.L1)
class TestSparseSearchShared(TestMilvusClientV2Base):
"""Shared collection for sparse vector search read-only tests.
Schema: int64(PK), float, varchar(65535), sparse_vector
Data: 4000 rows
Index: SPARSE_INVERTED_INDEX / IP
"""
shared_alias = "TestSparseSearchShared"
def setup_class(self):
super().setup_class(self)
self.collection_name = "TestSparseSearchShared" + cf.gen_unique_str("sparse_search")
@pytest.fixture(scope="class", autouse=True)
def prepare_collection(self, request):
client = self._client(alias=self.shared_alias)
schema = cf.gen_default_sparse_schema(auto_id=False)
self.create_collection(client, self.collection_name, schema=schema, force_teardown=False)
data = cf.gen_row_data_by_schema(nb=4000, schema=schema)
self.insert(client, self.collection_name, data=data)
self.flush(client, self.collection_name)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_sparse_vec_field_name,
index_type="SPARSE_INVERTED_INDEX", metric_type="IP", params={})
self.create_index(client, self.collection_name, index_params=idx)
self.load_collection(client, self.collection_name)
def teardown():
self.drop_collection(self._client(alias=self.shared_alias), self.collection_name)
request.addfinalizer(teardown)
@pytest.mark.tags(CaseLabel.L1)
def test_sparse_search_default(self):
"""
target: verify basic sparse vector search returns correct results with IP distance ordering
method: 1. search on shared sparse collection with default search params
2. check nq, limit, output_fields, and IP distance descending order via check_task
expected: search returns nq groups, each with limit results, distances sorted descending (IP)
"""
client = self._client(alias=self.shared_alias)
search_vectors = cf.gen_sparse_vectors(default_nq)
self.search(client, self.collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=ct.default_sparse_search_params,
limit=default_limit,
output_fields=[ct.default_sparse_vec_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit,
"metric": "IP",
"enable_milvus_client_api": True,
"pk_name": ct.default_int64_field_name,
"output_fields": [ct.default_sparse_vec_field_name]})
@pytest.mark.tags(CaseLabel.L1)
def test_sparse_search_with_filter(self):
"""
target: verify sparse search with scalar filter correctly filters results
method: 1. search with filter "int64 < 100" on shared sparse collection
2. check nq, limit, distance order via check_task
3. manually assert every returned hit satisfies int64 < 100
expected: all returned results have int64 < 100, no false positives from filter
"""
client = self._client(alias=self.shared_alias)
search_vectors = cf.gen_sparse_vectors(default_nq)
filter_limit = 100
expr = f"{ct.default_int64_field_name} < {filter_limit}"
search_res, _ = self.search(client, self.collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=ct.default_sparse_search_params,
limit=default_limit,
filter=expr,
output_fields=[ct.default_int64_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit,
"metric": "IP",
"enable_milvus_client_api": True,
"pk_name": ct.default_int64_field_name,
"output_fields": [ct.default_int64_field_name]})
for hits in search_res:
for hit in hits:
assert hit[ct.default_int64_field_name] < filter_limit, \
f"filter not effective: got {ct.default_int64_field_name}={hit[ct.default_int64_field_name]}"
@pytest.mark.tags(CaseLabel.L2)
def test_sparse_search_output_field(self):
"""
target: verify sparse search returns exactly the requested output fields
method: 1. search with output_fields=[float, sparse_vector]
2. check_task verifies returned field set matches requested fields exactly
expected: each hit contains float and sparse_vector fields, no extra or missing fields
"""
client = self._client(alias=self.shared_alias)
search_vectors = cf.gen_sparse_vectors(default_nq)
self.search(client, self.collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=ct.default_sparse_search_params,
limit=default_limit,
output_fields=[ct.default_float_field_name, ct.default_sparse_vec_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit,
"metric": "IP",
"enable_milvus_client_api": True,
"pk_name": ct.default_int64_field_name,
"output_fields": [ct.default_float_field_name,
ct.default_sparse_vec_field_name]})
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("batch_size", [10, 100, 500])
def test_sparse_search_iterator(self, batch_size):
"""
target: verify sparse search iterator works correctly with various batch sizes
method: 1. create search iterator with batch_size={10,100,500} and limit=500
2. check_search_iterator verifies: each batch <= batch_size, no duplicate PKs,
total results > 0
expected: iterator exhausts all results, PKs are unique across all batches
"""
client = self._client(alias=self.shared_alias)
search_vectors = cf.gen_sparse_vectors(1)
self.search_iterator(client, self.collection_name,
data=search_vectors,
batch_size=batch_size,
limit=500,
anns_field=ct.default_sparse_vec_field_name,
search_params=ct.default_sparse_search_params,
check_task=CheckTasks.check_search_iterator,
check_items={"batch_size": batch_size})
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("metric_type", ["L2", "COSINE"])
def test_sparse_search_invalid_metric_type(self, metric_type):
"""
target: verify sparse vector search rejects unsupported metric types
method: 1. search with metric_type={L2,COSINE} on sparse vector field (only IP is valid)
2. check_task verifies error response with code 1100
expected: search fails with error message containing "only IP is supported"
"""
client = self._client(alias=self.shared_alias)
search_vectors = cf.gen_sparse_vectors(1)
search_params = {"metric_type": metric_type, "params": {}}
self.search(client, self.collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=search_params,
limit=default_limit,
check_task=CheckTasks.err_res,
check_items={ct.err_code: 1100,
ct.err_msg: f"metric type not match: invalid parameter"
f"[expected=IP][actual={metric_type}]"})
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("nq", [1, 100])
def test_sparse_search_different_nq(self, nq):
"""
target: verify sparse search handles different numbers of query vectors correctly
method: 1. search with nq={1,100} sparse query vectors
2. check_task verifies len(search_res) == nq and each query returns limit results
expected: search returns exactly nq groups of results with correct limit and IP ordering
"""
client = self._client(alias=self.shared_alias)
search_vectors = cf.gen_sparse_vectors(nq, dim=ct.default_dim)
self.search(client, self.collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=ct.default_sparse_search_params,
limit=default_limit,
check_task=CheckTasks.check_search_results,
check_items={"nq": nq,
"limit": default_limit,
"metric": "IP",
"enable_milvus_client_api": True,
"pk_name": ct.default_int64_field_name})
class TestSparseSearchIndependent(TestMilvusClientV2Base):
"""Test cases that require independent collection setup (custom index/mmap/delete/dim)."""
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("index", ct.sparse_supported_index_types)
@pytest.mark.parametrize("inverted_index_algo", ct.inverted_index_algo)
def test_sparse_index_search(self, index, inverted_index_algo):
"""
target: verify all sparse index types × inverted_index_algo combinations produce correct search results
method: 1. create collection, insert 3000 rows, build index with parametrized type/algo
2. search with dim_max_score_ratio=1.05 and output sparse_vector field
3. check_task verifies nq, limit, output_fields, and IP distance descending order
expected: search returns correct results for every index type and algo variant
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
nb = 3000
# create collection with sparse schema
schema = cf.gen_default_sparse_schema(auto_id=False)
self.create_collection(client, collection_name, schema=schema)
# insert data
data = cf.gen_row_data_by_schema(nb=nb, schema=schema)
self.insert(client, collection_name, data=data)
self.flush(client, collection_name)
# create sparse index
params = cf.get_index_params_params(index)
params.update({"inverted_index_algo": inverted_index_algo})
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_sparse_vec_field_name,
index_type=index, metric_type="IP", params=params)
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
# search
_params = cf.get_search_params_params(index)
_params.update({"dim_max_score_ratio": 1.05})
search_params = {"metric_type": "IP", "params": _params}
search_vectors = cf.gen_sparse_vectors(default_nq)
self.search(client, collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=search_params,
limit=default_limit,
output_fields=[ct.default_sparse_vec_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit,
"metric": "IP",
"enable_milvus_client_api": True,
"pk_name": ct.default_int64_field_name,
"output_fields": [ct.default_sparse_vec_field_name]})
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("index", ct.sparse_supported_index_types)
@pytest.mark.parametrize("dim", [32768, ct.max_sparse_vector_dim])
def test_sparse_index_dim(self, index, dim):
"""
target: verify sparse index and search work correctly with high-dimensional sparse vectors
method: 1. create collection, insert sparse vectors with dim={32768, max_sparse_vector_dim}
(nb reduced to 100 for max_dim to avoid OOM)
2. build index and search with default_limit
3. check_task verifies nq, limit, and IP distance ordering
expected: search returns correct results even at extreme sparse dimensions
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
# reduce nb for extremely high dims to avoid OOM
nb = 100 if dim == ct.max_sparse_vector_dim else default_nb
# create collection with sparse schema
schema = cf.gen_default_sparse_schema(auto_id=False)
self.create_collection(client, collection_name, schema=schema)
# insert data — override sparse vectors with custom dim
data = cf.gen_row_data_by_schema(nb=nb, schema=schema)
sparse_vectors = cf.gen_sparse_vectors(nb, dim=dim)
for i in range(nb):
data[i][ct.default_sparse_vec_field_name] = sparse_vectors[i]
self.insert(client, collection_name, data=data)
# create sparse index
params = cf.get_index_params_params(index)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_sparse_vec_field_name,
index_type=index, metric_type="IP", params=params)
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
# search
search_vectors = cf.gen_sparse_vectors(default_nq)
self.search(client, collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=ct.default_sparse_search_params,
limit=default_limit,
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit,
"metric": "IP",
"enable_milvus_client_api": True,
"pk_name": ct.default_int64_field_name})
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("index", ct.sparse_supported_index_types)
@pytest.mark.parametrize("inverted_index_algo", ct.inverted_index_algo)
def test_sparse_index_enable_mmap_search(self, index, inverted_index_algo):
"""
target: verify sparse search works correctly after enabling mmap on both collection and index
method: 1. create collection, insert 3000 rows, build sparse index with parametrized type/algo
2. enable mmap on collection and index, assert properties are set to 'True'
3. insert 2000 more rows (start=3000), flush and load
4. search and verify nq, limit, output_fields, IP distance order via check_task
5. query specific PKs [0,1,10,100] and verify exact match on returned int64 values
expected: mmap does not affect search correctness; data from both batches is queryable
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
first_nb = 3000
schema = cf.gen_default_sparse_schema(auto_id=False)
self.create_collection(client, collection_name, schema=schema)
# insert first batch
data = cf.gen_row_data_by_schema(nb=first_nb, schema=schema, start=0)
self.insert(client, collection_name, data=data)
# create sparse index
params = cf.get_index_params_params(index)
params.update({"inverted_index_algo": inverted_index_algo})
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_sparse_vec_field_name,
index_type=index, metric_type="IP", params=params)
self.create_index(client, collection_name, index_params=idx)
# enable mmap on collection
self.alter_collection_properties(client, collection_name, properties={'mmap.enabled': True})
desc, _ = self.describe_collection(client, collection_name)
assert desc.get("properties", {}).get("mmap.enabled") == 'True'
# enable mmap on index
self.alter_index_properties(client, collection_name,
index_name=ct.default_sparse_vec_field_name,
properties={'mmap.enabled': True})
index_info, _ = self.describe_index(client, collection_name,
index_name=ct.default_sparse_vec_field_name)
assert index_info.get("mmap.enabled") == 'True'
# insert second batch
second_nb = ct.default_nb
data2 = cf.gen_row_data_by_schema(nb=second_nb, schema=schema, start=first_nb)
self.insert(client, collection_name, data=data2)
self.flush(client, collection_name)
self.load_collection(client, collection_name)
# search
_search_params = cf.get_search_params_params(index)
search_params = {"metric_type": "IP", "params": _search_params}
search_vectors = cf.gen_sparse_vectors(default_nq)
self.search(client, collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=search_params,
limit=default_limit,
output_fields=[ct.default_sparse_vec_field_name],
check_task=CheckTasks.check_search_results,
check_items={"nq": default_nq,
"limit": default_limit,
"metric": "IP",
"enable_milvus_client_api": True,
"pk_name": ct.default_int64_field_name,
"output_fields": [ct.default_sparse_vec_field_name]})
# query to verify data from both batches
expr_id_list = [0, 1, 10, 100]
term_expr = f'{ct.default_int64_field_name} in {expr_id_list}'
res, _ = self.query(client, collection_name, filter=term_expr,
output_fields=[ct.default_int64_field_name])
assert len(res) == len(expr_id_list)
returned_ids = sorted([r[ct.default_int64_field_name] for r in res])
assert returned_ids == sorted(expr_id_list)
@pytest.mark.tags(CaseLabel.L1)
@pytest.mark.parametrize("index", ct.sparse_supported_index_types)
def test_search_sparse_ratio(self, index):
"""
target: verify sparse search behavior with valid and invalid dim_max_score_ratio values
method: 1. create collection, insert 4000 rows, build index with drop_ratio_build=0.01
2. verify index exists via list_indexes
3. search with valid dim_max_score_ratio={0.5, 0.99, 1, 1.3}:
assert results non-empty and distances sorted descending (IP)
4. search with invalid dim_max_score_ratio={0.49, 1.4}:
assert error code 999 with range validation message
expected: valid ratios return correctly ordered results; out-of-range ratios are rejected
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
nb = 4000
drop_ratio_build = 0.01
schema = cf.gen_default_sparse_schema(auto_id=False)
self.create_collection(client, collection_name, schema=schema)
data = cf.gen_row_data_by_schema(nb=nb, schema=schema)
self.insert(client, collection_name, data=data)
self.flush(client, collection_name)
# create sparse index with drop_ratio_build
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_sparse_vec_field_name,
index_type=index, metric_type="IP",
params={"drop_ratio_build": drop_ratio_build})
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
# verify index exists
indexes, _ = self.list_indexes(client, collection_name)
assert ct.default_sparse_vec_field_name in indexes
# search with valid dim_max_score_ratio values
search_vectors = cf.gen_sparse_vectors(default_nq)
_params = {"drop_ratio_search": 0.2}
for dim_max_score_ratio in [0.5, 0.99, 1, 1.3]:
_params.update({"dim_max_score_ratio": dim_max_score_ratio})
search_params = {"metric_type": "IP", "params": _params}
search_res, _ = self.search(client, collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=search_params,
limit=default_limit)
assert len(search_res) == default_nq
for hits in search_res:
assert len(hits) > 0, f"no results for dim_max_score_ratio={dim_max_score_ratio}"
distances = [hit['distance'] for hit in hits]
assert distances == sorted(distances, reverse=True), \
f"distances not sorted descending for IP with ratio={dim_max_score_ratio}"
# search with invalid dim_max_score_ratio values
error = {ct.err_code: 999,
ct.err_msg: "should be in range [0.500000, 1.300000]"}
for invalid_ratio in [0.49, 1.4]:
_params.update({"dim_max_score_ratio": invalid_ratio})
search_params = {"metric_type": "IP", "params": _params}
self.search(client, collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=search_params,
limit=default_limit,
check_task=CheckTasks.err_res,
check_items=error)
@pytest.mark.tags(CaseLabel.L2)
@pytest.mark.parametrize("index", ct.sparse_supported_index_types)
def test_sparse_search_after_delete(self, index):
"""
target: verify deleted entities are excluded from sparse search results
method: 1. create collection, insert 2000 rows, build index and load
2. delete first 1000 rows (int64 in [0..999])
3. search and output int64 field
4. manually assert every returned PK is NOT in the deleted set
expected: no deleted PK appears in any search result across all nq queries
"""
client = self._client()
collection_name = cf.gen_collection_name_by_testcase_name()
nb = ct.default_nb
schema = cf.gen_default_sparse_schema(auto_id=False)
self.create_collection(client, collection_name, schema=schema)
data = cf.gen_row_data_by_schema(nb=nb, schema=schema)
self.insert(client, collection_name, data=data)
self.flush(client, collection_name)
params = cf.get_index_params_params(index)
idx = self.prepare_index_params(client)[0]
idx.add_index(field_name=ct.default_sparse_vec_field_name,
index_type=index, metric_type="IP", params=params)
self.create_index(client, collection_name, index_params=idx)
self.load_collection(client, collection_name)
# delete first half
delete_ids = list(range(nb // 2))
delete_expr = f"{ct.default_int64_field_name} in {delete_ids}"
self.delete(client, collection_name, filter=delete_expr)
# search and verify deleted PKs not in results
search_vectors = cf.gen_sparse_vectors(default_nq)
search_res, _ = self.search(client, collection_name,
data=search_vectors,
anns_field=ct.default_sparse_vec_field_name,
search_params=ct.default_sparse_search_params,
limit=default_limit,
output_fields=[ct.default_int64_field_name])
assert len(search_res) == default_nq
deleted_set = set(delete_ids)
for hits in search_res:
assert len(hits) > 0
for hit in hits:
assert hit[ct.default_int64_field_name] not in deleted_set, \
f"deleted PK {hit[ct.default_int64_field_name]} found in search results"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,739 @@
"""
Test cases for three-valued logic with nullable fields.
Related PR: https://github.com/milvus-io/milvus/pull/47333
Related Issue: https://github.com/milvus-io/milvus/issues/46820
These tests verify correct behavior of:
1. IS NULL / IS NOT NULL expressions
2. NOT operator with NULL values
3. AND/OR short-circuit logic with NULL values
4. De Morgan's law equivalences with NULL
5. Search with filter expressions involving NULL
"""
import pytest
from base.client_v2_base import TestMilvusClientV2Base
from common import common_func as cf
from common import common_type as ct
from common.common_type import CaseLabel
from pymilvus import DataType
from utils.util_log import test_log as log
prefix = "three_valued_logic"
default_dim = ct.default_dim
@pytest.mark.xdist_group("TestMilvusClientThreeValuedLogic")
class TestMilvusClientThreeValuedLogic(TestMilvusClientV2Base):
"""
Test cases for three-valued logic with nullable fields.
Uses shared collection pattern - collection created once in prepare_collection
and reused by all tests (read-only verification).
"""
def setup_class(self):
super().setup_class(self)
self.collection_name = cf.gen_unique_str(prefix)
self.pk_field = "id"
self.vector_field = "vec"
self.nullable_a_field = "nullable_a"
self.nullable_b_field = "nullable_b"
self.non_null_field = "non_null_int"
self.dim = default_dim
self.nb = 40
self.datas = []
@pytest.fixture(scope="class", autouse=True)
def prepare_collection(self, request):
"""
Initialize collection with nullable fields before test class runs.
Data pattern:
- nullable_a: NULL for IDs 0-19, NOT NULL (id*10) for IDs 20-39
- nullable_b: NULL for IDs 0-9 and 20-29, NOT NULL (id*100) for IDs 10-19 and 30-39
"""
client = self._client()
# Create schema with nullable fields
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(self.pk_field, DataType.INT64, is_primary=True)
schema.add_field(self.vector_field, DataType.FLOAT_VECTOR, dim=self.dim)
schema.add_field(self.nullable_a_field, DataType.INT64, nullable=True)
schema.add_field(self.nullable_b_field, DataType.INT64, nullable=True)
schema.add_field(self.non_null_field, DataType.INT64)
# Create collection
self.create_collection(client, self.collection_name, schema=schema, force_teardown=False)
log.info(f"Created shared collection: {self.collection_name}")
# Create index
index_params = self.prepare_index_params(client)[0]
index_params.add_index(self.vector_field, index_type="FLAT", metric_type="L2")
self.create_index(client, self.collection_name, index_params=index_params)
# Generate and insert test data
vectors = cf.gen_vectors(self.nb, self.dim)
for i in range(self.nb):
row = {
self.pk_field: i,
self.vector_field: vectors[i],
self.non_null_field: i,
# nullable_a: NULL for IDs 0-19, NOT NULL (id*10) for IDs 20-39
self.nullable_a_field: None if i < 20 else i * 10,
# nullable_b: NULL for IDs 0-9 and 20-29, NOT NULL (id*100) for IDs 10-19 and 30-39
self.nullable_b_field: None if (i < 10 or 20 <= i < 30) else i * 100,
}
self.datas.append(row)
self.insert(client, self.collection_name, self.datas)
self.flush(client, self.collection_name)
self.load_collection(client, self.collection_name)
log.info(f"Inserted {self.nb} rows into shared collection")
def teardown():
self.drop_collection(self._client(), self.collection_name)
log.info(f"Dropped shared collection: {self.collection_name}")
request.addfinalizer(teardown)
# ========================================================================
# Helper Methods - Calculate Expected Results from Data
# ========================================================================
def get_ids_where(self, predicate):
"""Get sorted list of IDs where predicate(row) is True."""
return sorted([row[self.pk_field] for row in self.datas if predicate(row)])
def ids_a_is_null(self):
"""IDs where nullable_a IS NULL."""
return self.get_ids_where(lambda r: r[self.nullable_a_field] is None)
def ids_a_is_not_null(self):
"""IDs where nullable_a IS NOT NULL."""
return self.get_ids_where(lambda r: r[self.nullable_a_field] is not None)
def ids_b_is_null(self):
"""IDs where nullable_b IS NULL."""
return self.get_ids_where(lambda r: r[self.nullable_b_field] is None)
def ids_b_is_not_null(self):
"""IDs where nullable_b IS NOT NULL."""
return self.get_ids_where(lambda r: r[self.nullable_b_field] is not None)
def ids_both_null(self):
"""IDs where both nullable_a AND nullable_b are NULL."""
return self.get_ids_where(lambda r: r[self.nullable_a_field] is None and r[self.nullable_b_field] is None)
def ids_either_null(self):
"""IDs where nullable_a OR nullable_b is NULL."""
return self.get_ids_where(lambda r: r[self.nullable_a_field] is None or r[self.nullable_b_field] is None)
def ids_both_not_null(self):
"""IDs where both nullable_a AND nullable_b are NOT NULL."""
return self.get_ids_where(
lambda r: r[self.nullable_a_field] is not None and r[self.nullable_b_field] is not None
)
def ids_a_not_null_and_gt(self, value):
"""IDs where nullable_a IS NOT NULL AND nullable_a > value."""
return self.get_ids_where(lambda r: r[self.nullable_a_field] is not None and r[self.nullable_a_field] > value)
def ids_a_null_or_a_gte(self, value):
"""IDs where nullable_a IS NULL OR nullable_a >= value."""
return self.get_ids_where(lambda r: r[self.nullable_a_field] is None or r[self.nullable_a_field] >= value)
def ids_a_null_or_a_gt(self, value):
"""IDs where nullable_a IS NULL OR nullable_a > value."""
return self.get_ids_where(lambda r: r[self.nullable_a_field] is None or r[self.nullable_a_field] > value)
def ids_complex_nested(self):
"""IDs for NOT (((A IS NOT NULL) AND (A > 200)) AND (B IS NOT NULL))."""
# Inner: (A NOT NULL AND A > 200) AND B NOT NULL
inner = self.get_ids_where(
lambda r: (
(r[self.nullable_a_field] is not None and r[self.nullable_a_field] > 200)
and r[self.nullable_b_field] is not None
)
)
# NOT of inner = all IDs except inner
all_ids = set(row[self.pk_field] for row in self.datas)
return sorted(all_ids - set(inner))
def ids_pk_lt_and_a_null(self, pk_limit):
"""IDs where id < pk_limit AND nullable_a IS NULL."""
return self.get_ids_where(lambda r: r[self.pk_field] < pk_limit and r[self.nullable_a_field] is None)
# ========================================================================
# Basic IS NULL / IS NOT NULL Tests
# ========================================================================
@pytest.mark.tags(CaseLabel.L1)
def test_is_null_basic(self):
"""
target: test basic IS NULL expression
method: query with "nullable_a IS NULL" filter
expected: return rows where nullable_a is NULL
"""
client = self._client()
res = self.query(
client, self.collection_name, filter=f"{self.nullable_a_field} IS NULL", output_fields=[self.pk_field]
)[0]
expected_ids = self.ids_a_is_null()
actual_ids = sorted([r[self.pk_field] for r in res])
assert actual_ids == expected_ids, f"Expected {expected_ids}, got {actual_ids}"
@pytest.mark.tags(CaseLabel.L1)
def test_is_not_null_basic(self):
"""
target: test basic IS NOT NULL expression
method: query with "nullable_a IS NOT NULL" filter
expected: return rows where nullable_a is NOT NULL
"""
client = self._client()
res = self.query(
client, self.collection_name, filter=f"{self.nullable_a_field} IS NOT NULL", output_fields=[self.pk_field]
)[0]
expected_ids = self.ids_a_is_not_null()
actual_ids = sorted([r[self.pk_field] for r in res])
assert actual_ids == expected_ids, f"Expected {expected_ids}, got {actual_ids}"
# ========================================================================
# NOT Equivalence Tests (Core Bug Fix Verification)
# ========================================================================
@pytest.mark.tags(CaseLabel.L1)
def test_not_is_not_null_equivalence(self):
"""
target: test NOT (field IS NOT NULL) equals field IS NULL
method: compare results of both expressions
expected: identical results
This is the critical bug fixed in PR #47333.
"""
client = self._client()
res1 = self.query(
client, self.collection_name, filter=f"{self.nullable_a_field} IS NULL", output_fields=[self.pk_field]
)[0]
res2 = self.query(
client,
self.collection_name,
filter=f"NOT ({self.nullable_a_field} IS NOT NULL)",
output_fields=[self.pk_field],
)[0]
expected_ids = self.ids_a_is_null()
ids1 = sorted([r[self.pk_field] for r in res1])
ids2 = sorted([r[self.pk_field] for r in res2])
assert ids1 == ids2 == expected_ids, f"Expected {expected_ids}, got {ids1} and {ids2}"
@pytest.mark.tags(CaseLabel.L1)
def test_not_is_null_equivalence(self):
"""
target: test NOT (field IS NULL) equals field IS NOT NULL
method: compare results of both expressions
expected: identical results
"""
client = self._client()
res1 = self.query(
client, self.collection_name, filter=f"{self.nullable_a_field} IS NOT NULL", output_fields=[self.pk_field]
)[0]
res2 = self.query(
client, self.collection_name, filter=f"NOT ({self.nullable_a_field} IS NULL)", output_fields=[self.pk_field]
)[0]
expected_ids = self.ids_a_is_not_null()
ids1 = sorted([r[self.pk_field] for r in res1])
ids2 = sorted([r[self.pk_field] for r in res2])
assert ids1 == ids2 == expected_ids, f"Expected {expected_ids}, got {ids1} and {ids2}"
# ========================================================================
# Multiple NOT Tests
# ========================================================================
@pytest.mark.tags(CaseLabel.L2)
def test_double_not_cancellation(self):
"""
target: test NOT (NOT (expr)) equals expr
method: query with NOT (NOT (nullable_a IS NULL))
expected: same as IS NULL
"""
client = self._client()
res = self.query(
client,
self.collection_name,
filter=f"NOT (NOT ({self.nullable_a_field} IS NULL))",
output_fields=[self.pk_field],
)[0]
expected_ids = self.ids_a_is_null()
actual_ids = sorted([r[self.pk_field] for r in res])
assert actual_ids == expected_ids, f"Expected {expected_ids}, got {actual_ids}"
@pytest.mark.tags(CaseLabel.L2)
def test_triple_not(self):
"""
target: test triple NOT operation
method: query with NOT (NOT (NOT (nullable_a IS NULL)))
expected: same as NOT (IS NULL) = IS NOT NULL
"""
client = self._client()
res = self.query(
client,
self.collection_name,
filter=f"NOT (NOT (NOT ({self.nullable_a_field} IS NULL)))",
output_fields=[self.pk_field],
)[0]
expected_ids = self.ids_a_is_not_null()
actual_ids = sorted([r[self.pk_field] for r in res])
assert actual_ids == expected_ids, f"Expected {expected_ids}, got {actual_ids}"
# ========================================================================
# Multi-Field NULL Tests
# ========================================================================
@pytest.mark.tags(CaseLabel.L1)
def test_and_both_null_fields(self):
"""
target: test AND with both fields being NULL
method: query (nullable_a IS NULL) AND (nullable_b IS NULL)
expected: rows where both fields are NULL
"""
client = self._client()
res = self.query(
client,
self.collection_name,
filter=f"({self.nullable_a_field} IS NULL) AND ({self.nullable_b_field} IS NULL)",
output_fields=[self.pk_field],
)[0]
expected_ids = self.ids_both_null()
actual_ids = sorted([r[self.pk_field] for r in res])
assert actual_ids == expected_ids, f"Expected {expected_ids}, got {actual_ids}"
@pytest.mark.tags(CaseLabel.L1)
def test_or_either_null_field(self):
"""
target: test OR with either field being NULL
method: query (nullable_a IS NULL) OR (nullable_b IS NULL)
expected: rows where at least one field is NULL
"""
client = self._client()
res = self.query(
client,
self.collection_name,
filter=f"({self.nullable_a_field} IS NULL) OR ({self.nullable_b_field} IS NULL)",
output_fields=[self.pk_field],
)[0]
expected_ids = self.ids_either_null()
actual_ids = sorted([r[self.pk_field] for r in res])
assert actual_ids == expected_ids, f"Expected {expected_ids}, got {actual_ids}"
# ========================================================================
# De Morgan's Law Tests
# ========================================================================
@pytest.mark.tags(CaseLabel.L1)
def test_de_morgan_not_and_to_or(self):
"""
target: test De Morgan's law: NOT (A AND B) = (NOT A) OR (NOT B)
method: NOT ((A IS NOT NULL) AND (B IS NOT NULL))
expected: equivalent to (A IS NULL) OR (B IS NULL)
Tests AND short-circuit fix in PR #47333.
"""
client = self._client()
res = self.query(
client,
self.collection_name,
filter=f"NOT (({self.nullable_a_field} IS NOT NULL) AND ({self.nullable_b_field} IS NOT NULL))",
output_fields=[self.pk_field],
)[0]
# NOT (both NOT NULL) = at least one is NULL
expected_ids = self.ids_either_null()
actual_ids = sorted([r[self.pk_field] for r in res])
assert actual_ids == expected_ids, f"Expected {expected_ids}, got {actual_ids}"
@pytest.mark.tags(CaseLabel.L1)
def test_de_morgan_not_or_to_and(self):
"""
target: test De Morgan's law: NOT (A OR B) = (NOT A) AND (NOT B)
method: NOT ((A IS NULL) OR (B IS NULL))
expected: equivalent to (A IS NOT NULL) AND (B IS NOT NULL)
"""
client = self._client()
res = self.query(
client,
self.collection_name,
filter=f"NOT (({self.nullable_a_field} IS NULL) OR ({self.nullable_b_field} IS NULL))",
output_fields=[self.pk_field],
)[0]
# NOT (either NULL) = both NOT NULL
expected_ids = self.ids_both_not_null()
actual_ids = sorted([r[self.pk_field] for r in res])
assert actual_ids == expected_ids, f"Expected {expected_ids}, got {actual_ids}"
# ========================================================================
# NULL with Value Comparison Tests
# ========================================================================
@pytest.mark.tags(CaseLabel.L2)
def test_null_with_value_comparison(self):
"""
target: test NULL check combined with value comparison
method: (nullable_a IS NOT NULL) AND (nullable_a > 250)
expected: rows where nullable_a is not null and > 250
"""
client = self._client()
threshold = 250
res = self.query(
client,
self.collection_name,
filter=f"({self.nullable_a_field} IS NOT NULL) AND ({self.nullable_a_field} > {threshold})",
output_fields=[self.pk_field],
)[0]
expected_ids = self.ids_a_not_null_and_gt(threshold)
actual_ids = sorted([r[self.pk_field] for r in res])
assert actual_ids == expected_ids, f"Expected {expected_ids}, got {actual_ids}"
@pytest.mark.tags(CaseLabel.L2)
def test_not_combined_null_and_value(self):
"""
target: test NOT on combined NULL and value comparison
method: NOT ((nullable_a IS NOT NULL) AND (nullable_a < 250))
expected: (A IS NULL) OR (A >= 250)
"""
client = self._client()
threshold = 250
res = self.query(
client,
self.collection_name,
filter=f"NOT (({self.nullable_a_field} IS NOT NULL) AND ({self.nullable_a_field} < {threshold}))",
output_fields=[self.pk_field],
)[0]
expected_ids = self.ids_a_null_or_a_gte(threshold)
actual_ids = sorted([r[self.pk_field] for r in res])
assert actual_ids == expected_ids, f"Expected {expected_ids}, got {actual_ids}"
@pytest.mark.tags(CaseLabel.L2)
def test_complex_nested_not(self):
"""
target: test complex nested NOT expression
method: NOT (((A IS NOT NULL) AND (A > 200)) AND (B IS NOT NULL))
expected: complement of rows matching inner condition
"""
client = self._client()
res = self.query(
client,
self.collection_name,
filter=f"NOT ((({self.nullable_a_field} IS NOT NULL) AND ({self.nullable_a_field} > 200)) AND ({self.nullable_b_field} IS NOT NULL))",
output_fields=[self.pk_field],
)[0]
expected_ids = self.ids_complex_nested()
actual_ids = sorted([r[self.pk_field] for r in res])
assert actual_ids == expected_ids, f"Expected {expected_ids}, got {actual_ids}"
# ========================================================================
# Mixed Filter Tests
# ========================================================================
@pytest.mark.tags(CaseLabel.L1)
def test_and_with_id_filter(self):
"""
target: test AND with id filter and NULL check
method: (id < 15) AND (nullable_a IS NULL)
expected: rows where id < 15 and nullable_a is NULL
"""
client = self._client()
pk_limit = 15
res = self.query(
client,
self.collection_name,
filter=f"({self.pk_field} < {pk_limit}) AND ({self.nullable_a_field} IS NULL)",
output_fields=[self.pk_field],
)[0]
expected_ids = self.ids_pk_lt_and_a_null(pk_limit)
actual_ids = sorted([r[self.pk_field] for r in res])
assert actual_ids == expected_ids, f"Expected {expected_ids}, got {actual_ids}"
@pytest.mark.tags(CaseLabel.L1)
def test_or_with_null_and_value(self):
"""
target: test OR with NULL check and value comparison
method: (nullable_a IS NULL) OR (nullable_a > 350)
expected: rows where nullable_a is NULL or > 350
"""
client = self._client()
threshold = 350
res = self.query(
client,
self.collection_name,
filter=f"({self.nullable_a_field} IS NULL) OR ({self.nullable_a_field} > {threshold})",
output_fields=[self.pk_field],
)[0]
expected_ids = self.ids_a_null_or_a_gt(threshold)
actual_ids = sorted([r[self.pk_field] for r in res])
assert actual_ids == expected_ids, f"Expected {expected_ids}, got {actual_ids}"
# ========================================================================
# Search with NULL Filter Tests
# ========================================================================
@pytest.mark.tags(CaseLabel.L2)
def test_search_with_not_is_not_null_filter(self):
"""
target: test search with NOT (field IS NOT NULL) filter
method: search with filter="NOT (nullable_a IS NOT NULL)"
expected: results only from rows where nullable_a IS NULL
"""
client = self._client()
vectors = cf.gen_vectors(1, self.dim)
search_res = self.search(
client,
self.collection_name,
vectors,
filter=f"NOT ({self.nullable_a_field} IS NOT NULL)",
anns_field=self.vector_field,
limit=100,
output_fields=[self.pk_field],
)[0]
expected_ids = set(self.ids_a_is_null())
# search_res[0] is the hits for the first query vector
actual_ids = set(hit.get("id") for hit in search_res[0])
assert actual_ids == expected_ids, f"Expected {expected_ids}, got {actual_ids}"
@pytest.mark.tags(CaseLabel.L2)
def test_search_with_complex_null_filter(self):
"""
target: test search with complex NULL filter
method: search with NOT ((A IS NOT NULL) AND (B IS NOT NULL))
expected: results from rows where at least one field is NULL
"""
client = self._client()
vectors = cf.gen_vectors(1, self.dim)
search_res = self.search(
client,
self.collection_name,
vectors,
filter=f"NOT (({self.nullable_a_field} IS NOT NULL) AND ({self.nullable_b_field} IS NOT NULL))",
anns_field=self.vector_field,
limit=100,
output_fields=[self.pk_field],
)[0]
expected_ids = set(self.ids_either_null())
# search_res[0] is the hits for the first query vector
actual_ids = set(hit.get("id") for hit in search_res[0])
assert actual_ids == expected_ids, f"Expected {expected_ids}, got {actual_ids}"
@pytest.mark.xdist_group("TestMilvusClientThreeValuedLogicIssue46972")
class TestMilvusClientThreeValuedLogicIssue46972(TestMilvusClientV2Base):
"""
Test cases for issue #46972: False OR False = True bug in complex JSON expressions.
Related Issue: https://github.com/milvus-io/milvus/issues/46972
This tests the critical logic error where complex OR expressions with
nullable fields and JSON fields incorrectly evaluate False OR False as True.
"""
def setup_class(self):
super().setup_class(self)
self.collection_name = cf.gen_unique_str(f"{prefix}_issue46972")
self.pk_field = "id"
self.vector_field = "vector"
self.dim = 128
self.datas = []
@pytest.fixture(scope="class", autouse=True)
def prepare_collection(self, request):
"""
Initialize collection with nullable fields and JSON field for issue #46972.
"""
client = self._client()
# Create schema matching the issue reproduction case
schema = self.create_schema(client, enable_dynamic_field=True)[0]
schema.add_field(self.pk_field, DataType.INT64, is_primary=True)
schema.add_field(self.vector_field, DataType.FLOAT_VECTOR, dim=self.dim)
schema.add_field("c0", DataType.DOUBLE, nullable=True)
schema.add_field("c1", DataType.BOOL, nullable=True)
schema.add_field("c2", DataType.INT64, nullable=True)
schema.add_field("c3", DataType.DOUBLE, nullable=True)
schema.add_field("c4", DataType.DOUBLE, nullable=True)
schema.add_field("c5", DataType.BOOL, nullable=True)
schema.add_field("c6", DataType.INT64, nullable=True)
schema.add_field("c7", DataType.BOOL, nullable=True)
schema.add_field("c8", DataType.VARCHAR, max_length=512, nullable=True)
schema.add_field("c9", DataType.DOUBLE, nullable=True)
schema.add_field("c10", DataType.VARCHAR, max_length=512, nullable=True)
schema.add_field("c11", DataType.INT64, nullable=True)
schema.add_field("c12", DataType.BOOL, nullable=True)
schema.add_field("c13", DataType.INT64, nullable=True)
schema.add_field("c14", DataType.DOUBLE, nullable=True)
schema.add_field("c15", DataType.DOUBLE, nullable=True)
schema.add_field("c16", DataType.DOUBLE, nullable=True)
schema.add_field("c17", DataType.BOOL, nullable=True)
schema.add_field("meta_json", DataType.JSON, nullable=True)
schema.add_field("tags_array", DataType.ARRAY, element_type=DataType.INT64, max_capacity=50, nullable=True)
self.create_collection(client, self.collection_name, schema=schema, force_teardown=False)
log.info(f"Created collection for issue #46972: {self.collection_name}")
# Create index
index_params = self.prepare_index_params(client)[0]
index_params.add_index(
self.vector_field, index_type="HNSW", metric_type="L2", params={"M": 32, "efConstruction": 256}
)
self.create_index(client, self.collection_name, index_params=index_params)
# Insert the exact test data from the issue
self.datas = [
{
"id": 1051,
"vector": [0.1] * self.dim,
"c0": None,
"c1": True,
"c2": 55943,
"c3": 2379.7519128726576,
"c4": None,
"c5": True,
"c6": -57942,
"c7": False,
"c8": "Vaxr5xzbWPHJazy9loD",
"c9": 1682.7331496087677,
"c10": None,
"c11": -62195,
"c12": False,
"c13": 71210,
"c14": None,
"c15": 2884.9004720171306,
"c16": 4866.809897346821,
"c17": True,
"meta_json": {
"price": 561,
"color": "Green",
"active": False,
"config": {"version": 7},
"history": [7, 20, 88],
"random_payload": 12168,
},
"tags_array": [73],
}
]
self.insert(client, self.collection_name, self.datas)
self.flush(client, self.collection_name)
self.load_collection(client, self.collection_name)
log.info(f"Inserted {len(self.datas)} rows for issue #46972 test")
def teardown():
self.drop_collection(self._client(), self.collection_name)
log.info(f"Dropped collection: {self.collection_name}")
request.addfinalizer(teardown)
@pytest.mark.tags(CaseLabel.L1)
def test_issue_46972_false_or_false_complex_json(self):
"""
target: test that False OR False = False (not True) with complex JSON expressions
method: use the expressions from issue #46972
expected: left expr is False, right expr is False, combined is False
This is the critical bug fixed in PR #47333: complex expressions with
JSON fields and nullable fields incorrectly evaluated (False OR False) as True.
Note: the original issue #46972 expressions contained bare `null is null`
/ `null is not null` sub-terms, which only ever "worked" by accident a
bare `null` used as a left-value was silently misparsed as a dynamic JSON
key. That misparse is now rejected at parse time (a bare `null` is not a
valid left-value), so those sub-terms are replaced with their exact
semantic equivalents `true` (`null is null`) and `false`
(`null is not null`). This keeps every sub-clause's boolean value — and
thus the False-OR-False regression coverage identical.
"""
client = self._client()
# Block A: Left Expression (should be False for test data)
expr_left = """
(((meta_json["config"]["version"] == 9 or (meta_json["history"][0] > 49 or (meta_json["history"][0] > 47 and (meta_json["price"] > 294 and meta_json["price"] < 379)))) and (((meta_json["config"]["version"] == 7 or meta_json["history"][0] > 27) or (tags_array is not null or true)) or c17 == false)) or ((((c14 <= 863.28694295187 or (c10 != "kaKmPWPbAaEFnHzX" and c10 != "ZmBmv")) and ((c4 > 1113.2711377477458 or c13 >= -76839) or (meta_json["config"]["version"] == 3 or (meta_json["price"] > 150 and meta_json["price"] < 237)))) and c7 == false) and ((((c1 is not null and true) or (c12 == false and meta_json["config"]["version"] == 4)) or (((meta_json["active"] == true and meta_json["color"] == "Blue") and c12 == true) and (c6 is not null or c13 == 180192))) or (c4 < 105376.953125 or ((c15 <= 3484.876420871185 or c1 == false) or (meta_json["config"]["version"] == 9 and c10 != "OjcbvwxZ5LfV2PZNPgS7"))))))
"""
# Block B: Right Expression (should be False for test data)
expr_right = """
((((((c17 == false or exists(meta_json["non_exist"])) or c14 <= 5363.7872388701035) or meta_json["history"][0] > 72) or (((c4 is not null and c17 == true) and ((c2 < 60835 or c1 is null) or (c5 == false or meta_json["history"][0] > 64))) and ((c6 >= -56376 or c16 is not null) or ((c4 >= 812.9318963654309 and c16 >= 2907.7772038042867) and (meta_json["price"] > 449 and meta_json["price"] < 624))))) or (((((false and c13 < 180192) or (c13 < -71746 or c17 == true)) and ((meta_json["price"] > 407 and meta_json["price"] < 521) and (c8 like "w%" or c14 > 3021.9496428003613))) and (((c12 == false or (meta_json["price"] > 412 and meta_json["price"] < 571)) or (c9 < 1264.220988053753 or c6 != 2063)) or c5 == false)) and (meta_json["active"] == true and meta_json["color"] == "Red"))) or ((((c13 != 54759 and (c7 is null and (c17 == true or c9 <= 1067.5165787120216))) and (((c11 != 36936 or meta_json["history"][0] > 59) or ((meta_json["price"] > 354 and meta_json["price"] < 528) and c9 >= 135.84002581554114)) and ((c0 > 105381.9375 or exists(meta_json["non_exist"])) or (c12 == true or meta_json is null)))) or ((((c14 >= 2547.2285277180335 and c1 == false) and (meta_json["history"][0] > 37 or json_contains(meta_json["k_11"], "o"))) and ((c9 < 2869.0647504243566 and c4 > 449.2640493406897) or (meta_json["config"]["version"] == 9 and c16 < 3626.0660282789318))) and (((c11 <= -57792 or (meta_json["price"] > 320 and meta_json["price"] < 488)) and (meta_json["active"] == false and c3 >= 105383.3671875)) or ((c13 >= -32496 and c5 == true) and (meta_json["active"] == true and meta_json["color"] == "Blue"))))) and c13 <= 180192))
"""
# Combined expression
expr_combined = f"({expr_left}) OR ({expr_right})"
# Query with left expression
res_left = self.query(client, self.collection_name, filter=expr_left, output_fields=[self.pk_field])[0]
is_left_true = len(res_left) > 0
# Query with right expression
res_right = self.query(client, self.collection_name, filter=expr_right, output_fields=[self.pk_field])[0]
is_right_true = len(res_right) > 0
# Query with combined expression
res_combined = self.query(client, self.collection_name, filter=expr_combined, output_fields=[self.pk_field])[0]
is_combined_true = len(res_combined) > 0
log.info(f"Expr Left result: {is_left_true} (hits: {len(res_left)})")
log.info(f"Expr Right result: {is_right_true} (hits: {len(res_right)})")
log.info(f"Combined result: {is_combined_true} (hits: {len(res_combined)})")
# Verify: For this data, both expressions should be False
assert not is_left_true, "Left expression should be False for test data"
assert not is_right_true, "Right expression should be False for test data"
# The critical check: False OR False should NOT be True
assert not is_combined_true, (
f"BUG: (False OR False) evaluated to True! Combined result has {len(res_combined)} hits"
)
@pytest.mark.tags(CaseLabel.L2)
def test_issue_46972_simplified_false_or_false(self):
"""
target: test simplified False OR False scenario with nullable fields
method: create conditions that are both False due to NULL comparisons
expected: combined OR expression is also False
Verifies the fix handles NULL comparisons correctly in OR expressions.
"""
client = self._client()
# For the test data: c0=NULL, c4=NULL, c10=NULL, c14=NULL
# Expression 1: c0 > 100 (NULL > 100 = UNKNOWN, treated as False)
# Expression 2: c4 > 100 (NULL > 100 = UNKNOWN, treated as False)
expr_left = "c0 > 100"
expr_right = "c4 > 100"
expr_combined = f"({expr_left}) OR ({expr_right})"
res_left = self.query(client, self.collection_name, filter=expr_left, output_fields=[self.pk_field])[0]
res_right = self.query(client, self.collection_name, filter=expr_right, output_fields=[self.pk_field])[0]
res_combined = self.query(client, self.collection_name, filter=expr_combined, output_fields=[self.pk_field])[0]
log.info(f"Simplified test - Left: {len(res_left)}, Right: {len(res_right)}, Combined: {len(res_combined)}")
# Both should be empty (NULL comparisons are UNKNOWN)
assert len(res_left) == 0, "NULL > 100 should return 0 results"
assert len(res_right) == 0, "NULL > 100 should return 0 results"
# Combined should also be empty
assert len(res_combined) == 0, "Combined (NULL > 100) OR (NULL > 100) should return 0 results"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff