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
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:
@@ -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)
|
||||
+2012
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,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
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
Reference in New Issue
Block a user