Files
wehub-resource-sync 498b235461
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
chore: import upstream snapshot with attribution
2026-07-13 12:31:17 +08:00

1675 lines
72 KiB
Python

"""
Test Query Aggregation (GROUP BY + Aggregation Functions)
This module tests the Query Aggregation feature which supports:
- GROUP BY on scalar fields (single or multiple columns)
- Aggregation functions: COUNT, SUM, MIN, MAX, AVG
- Supported data types: Int8/16/32/64, Float, Double, VarChar, Timestamptz
- Not supported: JSON, Array, Vector fields
Test Plan: /Users/yanliang/fork/milvus/docs/test-plans/2026-01-26-query-aggregation-test-plan.md
PR: #44394
Issue: #36380
"""
import numpy as np
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 DataType
from utils.util_log import test_log as log
prefix = "query_aggregation"
default_nb = 3000
@pytest.mark.xdist_group("TestQueryAggregationSharedV2")
class TestQueryAggregationSharedV2(TestMilvusClientV2Base):
"""
Test Query Aggregation with Shared Collection (L0 + L1)
These tests use a single shared collection to avoid repeated setup overhead.
All tests are read-only and can safely share the same collection data.
Covers:
- Single/multiple column GROUP BY with all aggregation functions
- Global aggregation (no GROUP BY)
- Filter, limit combinations
- Various data types (Int, Double, VarChar, Timestamp)
- Edge cases (empty results, case sensitivity)
"""
def setup_class(self):
super().setup_class(self)
self.collection_name = "TestQueryAggregationShared" + cf.gen_unique_str("_")
self.pk_field_name = "pk"
self.c1_field_name = "c1"
self.c2_field_name = "c2"
self.c3_field_name = "c3"
self.c4_field_name = "c4"
self.ts_field_name = "ts"
self.vector_field_name = "c5"
self.c6_field_name = "c6"
self.c7_field_name = "c7_int8"
self.c8_field_name = "c8_int64"
self.c9_field_name = "c9_float"
self.c10_field_name = "c10_nullable_varchar"
self.c11_field_name = "c11_nullable_int16"
self.datas = []
@pytest.fixture(scope="class", autouse=True)
def prepare_data(self, request):
"""
Prepare collection with aggregation test data
Schema (with nullable aggregation fields to test NULL handling in aggregations):
- pk: VarChar (primary key, non-nullable)
- c1: VarChar (non-nullable, grouping field, 7 unique values)
- c2: Int16 (nullable, aggregation field - tests COUNT/SUM with NULL)
- c3: Int32 (non-nullable, aggregation field)
- c4: Double (nullable, aggregation field - tests AVG/MIN/MAX with NULL)
- ts: Int64 (non-nullable, timestamp field)
- c5: FloatVector (non-nullable, dim=8)
- c6: VarChar (non-nullable, grouping field, 7 unique values)
- c7_int8: Int8 (non-nullable, grouping field, 5 unique values)
- c8_int64: Int64 (non-nullable, grouping field, 5 unique values)
- c9_float: Float (nullable, aggregation field - tests aggregations with NULL)
- c10_nullable_varchar: VarChar (nullable, grouping field, 7 unique values + ~15% NULL)
- c11_nullable_int16: Int16 (nullable, grouping field, 5 unique values + ~15% NULL)
Note: Nullable fields (c2, c4, c9_float) contain ~10-15% NULL values to test
that aggregation functions correctly ignore NULL values.
Nullable GROUP BY fields (c10, c11) test fix for issue #47350 (PR #47445).
"""
client = self._client()
# Create schema with nullable aggregation fields (GROUP BY fields are non-nullable in this shared collection)
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field(self.pk_field_name, DataType.VARCHAR, is_primary=True, max_length=100)
# GROUP BY fields - all non-nullable in this shared collection
schema.add_field(self.c1_field_name, DataType.VARCHAR, max_length=100)
schema.add_field(self.c6_field_name, DataType.VARCHAR, max_length=100)
schema.add_field(self.c7_field_name, DataType.INT8)
schema.add_field(self.c8_field_name, DataType.INT64)
# Aggregation fields - some nullable to test NULL handling in aggregations
schema.add_field(self.c2_field_name, DataType.INT16, nullable=True)
schema.add_field(self.c3_field_name, DataType.INT32)
schema.add_field(self.c4_field_name, DataType.DOUBLE, nullable=True)
schema.add_field(self.c9_field_name, DataType.FLOAT, nullable=True)
# Nullable GROUP BY fields - tests fix for issue #47350
schema.add_field(self.c10_field_name, DataType.VARCHAR, max_length=100, nullable=True)
schema.add_field(self.c11_field_name, DataType.INT16, nullable=True)
# Other fields - non-nullable
schema.add_field(self.ts_field_name, DataType.INT64)
schema.add_field(self.vector_field_name, DataType.FLOAT_VECTOR, dim=8)
# Create collection
self.create_collection(client, self.collection_name, schema=schema, force_teardown=False)
# Generate test data (3000 rows) with nullable aggregation fields containing ~10-15% NULL values
unique_values_c1 = ["A", "B", "C", "D", "E", "F", "G"]
unique_values_c6 = ["X", "Y", "Z", "W", "V", "U", "T"]
unique_values_c7 = [1, 2, 3, 4, 5] # INT8 grouping values
unique_values_c8 = [100, 200, 300, 400, 500] # INT64 grouping values
unique_values_c9 = [1.0, 2.0, 3.0, 4.0, 5.0] # FLOAT aggregation values
unique_values_c10 = ["P", "Q", "R", "S", "T_v", "U_v", "V_v"] # Nullable VARCHAR grouping values
unique_values_c11 = [10, 20, 30, 40, 50] # Nullable INT16 grouping values
np.random.seed(19530)
rows = []
for i in range(default_nb):
# Helper function to randomly insert NULL for nullable aggregation fields (~15% probability)
def maybe_null(value):
return None if np.random.random() < 0.15 else value
row = {
self.pk_field_name: f"pk_{i}",
# GROUP BY fields - all non-nullable in this shared collection
self.c1_field_name: np.random.choice(unique_values_c1),
self.c6_field_name: np.random.choice(unique_values_c6),
self.c7_field_name: int(np.random.choice(unique_values_c7)),
self.c8_field_name: int(np.random.choice(unique_values_c8)),
# Aggregation fields - some nullable to test NULL handling
self.c2_field_name: maybe_null(int(np.random.randint(0, 100, dtype=np.int16))),
self.c4_field_name: maybe_null(float(np.random.uniform(0, 100))),
self.c9_field_name: maybe_null(float(np.random.choice(unique_values_c9))),
# Nullable GROUP BY fields (~15% NULL) - tests fix for #47350
self.c10_field_name: maybe_null(np.random.choice(unique_values_c10)),
self.c11_field_name: maybe_null(int(np.random.choice(unique_values_c11))),
# Other non-nullable fields
self.c3_field_name: int(np.random.randint(0, 1000, dtype=np.int32)),
self.ts_field_name: int(np.random.randint(1000000, 2000000, dtype=np.int64)),
self.vector_field_name: [float(x) for x in np.random.random(8)],
}
rows.append(row)
# Insert data
self.insert(client, self.collection_name, data=rows)
self.flush(client, self.collection_name)
# Create index on vectowr field
index_params = self.prepare_index_params(client)[0]
index_params.add_index(field_name=self.vector_field_name, metric_type="L2", index_type="FLAT", params={})
self.create_index(client, self.collection_name, index_params=index_params)
# Load collection
self.load_collection(client, self.collection_name)
# Store data for ground truth verification (on class, not instance)
self.__class__.datas = pd.DataFrame(rows)
log.info(f"Prepared collection {self.collection_name} with {default_nb} entities")
def teardown():
self.drop_collection(self._client(), self.collection_name)
request.addfinalizer(teardown)
@pytest.mark.tags(CaseLabel.L2)
def test_basic_group_by_count_no_filter_no_limit(self):
"""
target: test the most basic GROUP BY with COUNT without any filter or limit
method: query with only group_by_fields and output_fields, no filter/limit parameters
expected: should return all groups with correct count values
"""
client = self._client()
# Most basic aggregation: no filter, no limit - just group and count
# This should work but currently fails with "empty expression should be used with limit"
results, _ = self.query(
client,
self.collection_name,
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, "count(c2)"],
)
# Should return all 7 groups
assert len(results) == 7, f"Expected 7 groups, got {len(results)}"
# Calculate ground truth
ground_truth = self.datas.groupby(self.c1_field_name).agg(count_c2=(self.c2_field_name, "count")).reset_index()
# Verify each group's count
for result in results:
c1_value = result[self.c1_field_name]
expected = ground_truth[ground_truth[self.c1_field_name] == c1_value].iloc[0]
assert result["count(c2)"] == expected["count_c2"], (
f"COUNT mismatch for c1={c1_value}: {result['count(c2)']} != {expected['count_c2']}"
)
log.info(f"test_basic_group_by_count_no_filter_no_limit passed: {len(results)} groups verified")
@pytest.mark.tags(CaseLabel.L0)
def test_single_column_group_by_count_sum(self):
"""
target: test basic single column GROUP BY with COUNT and SUM
method: query with group_by_fields=["c1"], output_fields=["c1", "count(c2)", "sum(c3)"]
expected: returns 7 groups with correct count and sum values
"""
client = self._client()
# Execute query
results, _ = self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, "count(c2)", "sum(c3)"],
)
# Verify number of groups
assert len(results) == 7, f"Expected 7 groups, got {len(results)}"
# Calculate ground truth
ground_truth = (
self.datas.groupby(self.c1_field_name)
.agg(count_c2=(self.c2_field_name, "count"), sum_c3=(self.c3_field_name, "sum"))
.reset_index()
)
# Verify each group's aggregation values
for result in results:
c1_value = result[self.c1_field_name]
expected = ground_truth[ground_truth[self.c1_field_name] == c1_value].iloc[0]
assert result["count(c2)"] == expected["count_c2"], (
f"COUNT mismatch for c1={c1_value}: {result['count(c2)']} != {expected['count_c2']}"
)
assert result["sum(c3)"] == expected["sum_c3"], (
f"SUM mismatch for c1={c1_value}: {result['sum(c3)']} != {expected['sum_c3']}"
)
log.info(f"test_single_column_group_by_count_sum passed: {len(results)} groups verified")
@pytest.mark.tags(CaseLabel.L0)
def test_multi_column_group_by_min_max(self):
"""
target: test multi-column GROUP BY with MIN and MAX
method: query with group_by_fields=["c1", "c6"], output_fields=["c1", "c6", "min(c2)", "max(c2)"]
expected: returns correct groups with correct min and max values
"""
client = self._client()
# Execute query
results, _ = self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c1_field_name, self.c6_field_name],
output_fields=[self.c1_field_name, self.c6_field_name, "min(c2)", "max(c2)"],
)
# Verify number of groups (should be up to 49, but actual may be less)
assert len(results) > 0, "Expected at least 1 group"
log.info(f"Got {len(results)} groups from multi-column GROUP BY")
# Calculate ground truth
ground_truth = (
self.datas.groupby([self.c1_field_name, self.c6_field_name])
.agg(min_c2=(self.c2_field_name, "min"), max_c2=(self.c2_field_name, "max"))
.reset_index()
)
# Verify each group's aggregation values
for result in results:
c1_value = result[self.c1_field_name]
c6_value = result[self.c6_field_name]
expected = ground_truth[
(ground_truth[self.c1_field_name] == c1_value) & (ground_truth[self.c6_field_name] == c6_value)
].iloc[0]
assert result["min(c2)"] == expected["min_c2"], f"MIN mismatch for c1={c1_value}, c6={c6_value}"
assert result["max(c2)"] == expected["max_c2"], f"MAX mismatch for c1={c1_value}, c6={c6_value}"
log.info(f"test_multi_column_group_by_min_max passed: {len(results)} groups verified")
@pytest.mark.tags(CaseLabel.L1)
def test_single_group_by_multiple_aggregations(self):
"""
target: test single GROUP BY field with multiple AVG aggregations
method: query with group_by_fields=["c1"], output_fields=["c1", "avg(c2)", "avg(c3)", "avg(c4)"]
expected: returns groups with 3 AVG values, all of type double
"""
client = self._client()
results, _ = self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, "avg(c2)", "avg(c3)", "avg(c4)"],
)
assert len(results) == 7, f"Expected 7 groups, got {len(results)}"
# Calculate ground truth
ground_truth = (
self.datas.groupby(self.c1_field_name)
.agg(
avg_c2=(self.c2_field_name, "mean"),
avg_c3=(self.c3_field_name, "mean"),
avg_c4=(self.c4_field_name, "mean"),
)
.reset_index()
)
# Verify AVG values and check they are float/double type
for result in results:
c1_value = result[self.c1_field_name]
expected = ground_truth[ground_truth[self.c1_field_name] == c1_value].iloc[0]
# AVG should return float type
assert isinstance(result["avg(c2)"], (float, np.floating)), (
f"avg(c2) should be float type, got {type(result['avg(c2)'])}"
)
assert isinstance(result["avg(c3)"], (float, np.floating)), "avg(c3) should be float type"
assert isinstance(result["avg(c4)"], (float, np.floating)), "avg(c4) should be float type"
# Verify values (allow small floating point errors)
assert abs(result["avg(c2)"] - expected["avg_c2"]) < 0.01, f"AVG(c2) mismatch for c1={c1_value}"
assert abs(result["avg(c3)"] - expected["avg_c3"]) < 0.01, f"AVG(c3) mismatch for c1={c1_value}"
assert abs(result["avg(c4)"] - expected["avg_c4"]) < 0.01, f"AVG(c4) mismatch for c1={c1_value}"
log.info("test_single_group_by_multiple_aggregations passed")
@pytest.mark.tags(CaseLabel.L1)
def test_group_by_with_filter(self):
"""
target: test GROUP BY with filter expression
method: query with filter="c2 < 10", group_by_fields=["c1"], output_fields=["c1", "count(c2)", "max(c3)"]
expected: only rows with c2 < 10 participate in grouping
"""
client = self._client()
results, _ = self.query(
client,
self.collection_name,
filter="c2 < 10",
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, "count(c2)", "max(c3)"],
)
# Filter data first
filtered_data = self.datas[self.datas[self.c2_field_name] < 10]
ground_truth = (
filtered_data.groupby(self.c1_field_name)
.agg(count_c2=(self.c2_field_name, "count"), max_c3=(self.c3_field_name, "max"))
.reset_index()
)
# Number of groups may be less than 7 if some groups have no data after filter
assert len(results) <= 7, f"Expected at most 7 groups, got {len(results)}"
assert len(results) == len(ground_truth), f"Group count mismatch: {len(results)} != {len(ground_truth)}"
for result in results:
c1_value = result[self.c1_field_name]
expected = ground_truth[ground_truth[self.c1_field_name] == c1_value].iloc[0]
assert result["count(c2)"] == expected["count_c2"], f"COUNT mismatch for c1={c1_value} with filter"
assert result["max(c3)"] == expected["max_c3"], f"MAX mismatch for c1={c1_value} with filter"
log.info(f"test_group_by_with_filter passed: {len(results)} groups after filtering")
@pytest.mark.tags(CaseLabel.L1)
def test_group_by_with_limit(self):
"""
target: test GROUP BY with limit parameter (with and without filter)
method: query with group_by_fields=["c1"], limit, with/without filter
expected: returns at most N groups, aggregations computed correctly from filtered data
Note: This test covers both scenarios originally tested by test_group_by_with_limit
and test_filter_and_limit_with_aggregation.
"""
client = self._client()
# Scenario 1: limit without filter
limit = 3
results, _ = self.query(
client,
self.collection_name,
filter="",
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, "avg(c2)"],
limit=limit,
)
ground_truth = self.datas.groupby(self.c1_field_name).agg(avg_c2=(self.c2_field_name, "mean")).reset_index()
assert len(results) == min(limit, len(ground_truth)), (
f"Expected {min(limit, len(ground_truth))} groups, got {len(results)}"
)
for result in results:
c1_value = result[self.c1_field_name]
expected = ground_truth[ground_truth[self.c1_field_name] == c1_value].iloc[0]
assert abs(result["avg(c2)"] - expected["avg_c2"]) < 0.01, f"AVG(c2) mismatch for c1={c1_value}"
# Scenario 2: limit with filter - verify aggregation from filtered data
# Filter "c2 >= 50" keeps roughly 50% of non-NULL values (c2 ranges 0-99)
# This verifies that filter is applied before aggregation
results_filtered, _ = self.query(
client,
self.collection_name,
filter="c2 >= 50",
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, "count(c2)", "avg(c2)"],
limit=2,
)
filtered_data = self.datas[self.datas[self.c2_field_name] >= 50]
filtered_ground_truth = (
filtered_data.groupby(self.c1_field_name)
.agg(count_c2=(self.c2_field_name, "count"), avg_c2=(self.c2_field_name, "mean"))
.reset_index()
)
assert len(results_filtered) == min(2, len(filtered_ground_truth)), (
f"Expected {min(2, len(filtered_ground_truth))} groups, got {len(results_filtered)}"
)
# Verify aggregations are from filtered data (c2 >= 50)
for result in results_filtered:
c1_value = result[self.c1_field_name]
expected = filtered_ground_truth[filtered_ground_truth[self.c1_field_name] == c1_value].iloc[0]
assert result["count(c2)"] == expected["count_c2"], f"COUNT mismatch for c1={c1_value} with filter"
assert abs(result["avg(c2)"] - expected["avg_c2"]) < 0.01, f"AVG(c2) mismatch for c1={c1_value}"
assert result["avg(c2)"] >= 50, f"avg(c2) should be >= 50 after filter, got {result['avg(c2)']}"
log.info("test_group_by_with_limit passed: limit and filter+limit scenarios verified")
@pytest.mark.tags(CaseLabel.L1)
def test_varchar_min_max(self):
"""
target: test MIN/MAX on VarChar field
method: query with group_by_fields=["c1"], output_fields=["c1", "min(c6)", "max(c6)"]
expected: MIN/MAX returns VarChar type, sorted by lexicographical order
"""
client = self._client()
results, _ = self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, "min(c6)", "max(c6)"],
)
assert len(results) == 7, f"Expected 7 groups, got {len(results)}"
# Calculate ground truth
ground_truth = (
self.datas.groupby(self.c1_field_name)
.agg(min_c6=(self.c6_field_name, "min"), max_c6=(self.c6_field_name, "max"))
.reset_index()
)
for result in results:
c1_value = result[self.c1_field_name]
expected = ground_truth[ground_truth[self.c1_field_name] == c1_value].iloc[0]
# Verify values (lexicographical order)
assert result["min(c6)"] == expected["min_c6"], f"MIN(c6) mismatch for c1={c1_value}"
assert result["max(c6)"] == expected["max_c6"], f"MAX(c6) mismatch for c1={c1_value}"
# Verify type is string
assert isinstance(result["min(c6)"], str), "min(c6) should return string"
assert isinstance(result["max(c6)"], str), "max(c6) should return string"
log.info("test_varchar_min_max passed")
@pytest.mark.tags(CaseLabel.L1)
def test_timestamp_aggregation(self):
"""
target: test aggregation on timestamp field
method: query with group_by_fields=["c1"], output_fields=["c1", "count(ts)", "max(ts)"]
expected: timestamp grouping and aggregation work correctly
"""
client = self._client()
results, _ = self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, "count(ts)", "max(ts)"],
)
assert len(results) == 7, f"Expected 7 groups, got {len(results)}"
ground_truth = (
self.datas.groupby(self.c1_field_name)
.agg(count_ts=(self.ts_field_name, "count"), max_ts=(self.ts_field_name, "max"))
.reset_index()
)
for result in results:
c1_value = result[self.c1_field_name]
expected = ground_truth[ground_truth[self.c1_field_name] == c1_value].iloc[0]
assert result["count(ts)"] == expected["count_ts"], f"COUNT(ts) mismatch for c1={c1_value}"
assert result["max(ts)"] == expected["max_ts"], f"MAX(ts) mismatch for c1={c1_value}"
log.info("test_timestamp_aggregation passed")
@pytest.mark.tags(CaseLabel.L1)
def test_different_sum_return_types(self):
"""
target: test SUM return types for different numeric types
method: verify sum(c2) (Int16) -> int64, sum(c3) (Int32) -> int64, sum(c4) (Double) -> double
expected: integer SUM returns int64, float SUM returns double
"""
client = self._client()
# Test integer SUM (Int16)
results_int16, _ = self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, "sum(c2)"],
)
# Test integer SUM (Int32)
results_int32, _ = self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, "sum(c3)"],
)
# Test float SUM (Double)
results_double, _ = self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, "sum(c4)"],
)
# Verify return types
for result in results_int16:
# Int16 SUM should return int64
assert isinstance(result["sum(c2)"], (int, np.integer)), (
f"sum(c2) should return int type, got {type(result['sum(c2)'])}"
)
for result in results_int32:
# Int32 SUM should return int64
assert isinstance(result["sum(c3)"], (int, np.integer)), (
f"sum(c3) should return int type, got {type(result['sum(c3)'])}"
)
for result in results_double:
# Double SUM should return double
assert isinstance(result["sum(c4)"], (float, np.floating)), (
f"sum(c4) should return float type, got {type(result['sum(c4)'])}"
)
log.info("test_different_sum_return_types passed")
@pytest.mark.tags(CaseLabel.L1)
def test_avg_return_type(self):
"""
target: test AVG always returns double regardless of input type
method: verify avg(c2) (Int16), avg(c3) (Int32), avg(c4) (Double) all return double
expected: all AVG results are double type
"""
client = self._client()
results, _ = self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, "avg(c2)", "avg(c3)", "avg(c4)"],
)
for result in results:
# All AVG should return float/double type
assert isinstance(result["avg(c2)"], (float, np.floating)), (
f"avg(c2) should return float, got {type(result['avg(c2)'])}"
)
assert isinstance(result["avg(c3)"], (float, np.floating)), (
f"avg(c3) should return float, got {type(result['avg(c3)'])}"
)
assert isinstance(result["avg(c4)"], (float, np.floating)), (
f"avg(c4) should return float, got {type(result['avg(c4)'])}"
)
log.info("test_avg_return_type passed")
@pytest.mark.tags(CaseLabel.L1)
def test_empty_result_aggregation(self):
"""
target: test aggregation when filter matches no rows
method: query with filter that matches nothing, e.g., "c2 > 10000"
expected: returns empty result set
"""
client = self._client()
results, _ = self.query(
client,
self.collection_name,
filter="c2 > 10000",
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, "count(c2)"],
)
# Should return empty result set
assert len(results) == 0, f"Expected empty result, got {len(results)} groups"
log.info("test_empty_result_aggregation passed")
@pytest.mark.tags(CaseLabel.L0)
def test_global_aggregation_no_filter_no_limit(self):
"""
target: test global aggregation without GROUP BY, filter, or limit
method: query with only group_by_fields=[], output_fields=["count(c2)", "sum(c2)", "avg(c3)"]
expected: returns 1 row with global aggregation values for all data
Note: Unlike GROUP BY aggregation, global aggregation works without filter/limit
c2 is nullable, so COUNT(c2) excludes NULL values
"""
client = self._client()
# Most basic global aggregation: no GROUP BY, no filter, no limit
results, _ = self.query(
client, self.collection_name, group_by_fields=[], output_fields=["count(c2)", "sum(c2)", "avg(c3)"]
)
# Should return exactly 1 row
assert len(results) == 1, f"Expected 1 global aggregation row, got {len(results)}"
# Calculate ground truth for all data
# Note: c2 is nullable, so pandas count() excludes NULL, matching SQL behavior
expected_count = self.datas[self.c2_field_name].count() # COUNT excludes NULL
expected_sum = self.datas[self.c2_field_name].sum() # SUM excludes NULL
expected_avg = self.datas[self.c3_field_name].mean()
result = results[0]
assert result["count(c2)"] == expected_count, (
f"Global count mismatch: {result['count(c2)']} != {expected_count}"
)
assert result["sum(c2)"] == expected_sum, f"Global sum mismatch: {result['sum(c2)']} != {expected_sum}"
assert abs(result["avg(c3)"] - expected_avg) < 0.01, (
f"Global avg mismatch: {result['avg(c3)']} != {expected_avg}"
)
log.info("test_global_aggregation_no_filter_no_limit passed")
@pytest.mark.tags(CaseLabel.L1)
def test_global_aggregation_no_group_by(self):
"""
target: test global aggregation without GROUP BY with filter
method: query with group_by_fields=[], output_fields=["count(c2)", "sum(c2)", "avg(c3)"]
expected: returns 1 row with global aggregation values
Note: c2 is nullable, filter "c2 >= 0" excludes NULL rows (SQL three-valued logic)
COUNT(c2) also excludes NULL values in the aggregation
"""
client = self._client()
# Note: Milvus requires count(field_name), count(*) is not supported
# filter "c2 >= 0" excludes rows where c2 is NULL (SQL three-valued logic)
results, _ = self.query(
client,
self.collection_name,
filter="c2 >= 0",
group_by_fields=[],
output_fields=["count(c2)", "sum(c2)", "avg(c3)"],
)
# Should return 1 row
assert len(results) == 1, f"Expected 1 global aggregation row, got {len(results)}"
# Calculate ground truth
# Filter excludes NULL c2 rows, then COUNT(c2) counts non-NULL c2 values
filtered_data = self.datas[self.datas[self.c2_field_name] >= 0] # Excludes NULL
expected_count = filtered_data[self.c2_field_name].count() # COUNT excludes NULL
expected_sum = filtered_data[self.c2_field_name].sum() # SUM excludes NULL
expected_avg = filtered_data[self.c3_field_name].mean()
result = results[0]
assert result["count(c2)"] == expected_count, (
f"Global count mismatch: {result['count(c2)']} != {expected_count}"
)
assert result["sum(c2)"] == expected_sum, f"Global sum mismatch: {result['sum(c2)']} != {expected_sum}"
assert abs(result["avg(c3)"] - expected_avg) < 0.01, (
f"Global avg mismatch: {result['avg(c3)']} != {expected_avg}"
)
log.info("test_global_aggregation_no_group_by passed")
@pytest.mark.tags(CaseLabel.L1)
def test_count_star_without_group_by(self):
"""
target: test Milvus original count(*) functionality (without GROUP BY)
method: query with group_by_fields=[], output_fields=["count(*)"]
expected: returns 1 row with total count of all entities
Note: This tests the original Milvus count(*) feature, which is different from
aggregation count(field). count(*) counts all entities regardless of NULL values.
"""
client = self._client()
# count(*) without GROUP BY should return total entity count
results, _ = self.query(client, self.collection_name, filter="", group_by_fields=[], output_fields=["count(*)"])
# Should return 1 row with total count
assert len(results) == 1, f"Expected 1 row for count(*), got {len(results)}"
# count(*) should equal total number of entities (3000)
expected_count = len(self.datas) # 3000
assert results[0]["count(*)"] == expected_count, (
f"count(*) mismatch: {results[0]['count(*)']} != {expected_count}"
)
log.info("test_count_star_without_group_by passed")
@pytest.mark.tags(CaseLabel.L1)
def test_count_star_vs_count_field(self):
"""
target: test difference between count(*) and count(field) for nullable fields
method: compare count(*) result with count(field) result on nullable field
expected: count(*) counts all entities, count(field) excludes NULL values
Note: c2 is nullable with ~15% NULL values
- count(*) should return 3000 (all entities)
- count(c2) should return ~2550 (non-NULL entities)
"""
client = self._client()
# Get count(*) - counts all entities
results_star, _ = self.query(
client, self.collection_name, filter="", group_by_fields=[], output_fields=["count(*)"]
)
count_star = results_star[0]["count(*)"]
# Get count(c2) - excludes NULL values
results_field, _ = self.query(
client, self.collection_name, filter="", group_by_fields=[], output_fields=["count(c2)"]
)
count_field = results_field[0]["count(c2)"]
# Verify count(*) equals total entities
expected_total = len(self.datas) # 3000
assert count_star == expected_total, f"count(*) should equal total entities: {count_star} != {expected_total}"
# Verify count(c2) excludes NULL (should be less than count(*))
expected_non_null = self.datas[self.c2_field_name].count() # ~2550
assert count_field == expected_non_null, f"count(c2) should exclude NULL: {count_field} != {expected_non_null}"
# Verify count(*) > count(field) for nullable field
assert count_star > count_field, (
f"count(*) should be greater than count(nullable_field): {count_star} <= {count_field}"
)
log.info(f"count(*) = {count_star}, count(c2) = {count_field}, difference = {count_star - count_field}")
log.info("test_count_star_vs_count_field passed")
@pytest.mark.tags(CaseLabel.L1)
def test_count_star_and_count_field_together(self):
"""
target: test count(*) and count(nullable_field) in the same output_fields
method: query with output_fields=["count(*)", "count(c2)"] in a single request
expected: count(*) returns total rows, count(c2) returns non-NULL rows
Note: Regression test for https://github.com/milvus-io/milvus/issues/47509
When queried together, count(*) was incorrectly returning the same
value as count(nullable_field) instead of total row count.
"""
client = self._client()
# Query count(*) and count(nullable_field) together in a single request
results_together, _ = self.query(
client, self.collection_name, filter="", group_by_fields=[], output_fields=["count(*)", "count(c2)"]
)
count_star = results_together[0]["count(*)"]
count_field = results_together[0]["count(c2)"]
expected_total = len(self.datas) # 3000
expected_non_null = self.datas[self.c2_field_name].count()
# count(*) must return total entities, not non-NULL count
assert count_star == expected_total, (
f"count(*) should equal total entities when queried together: {count_star} != {expected_total}"
)
# count(c2) must return non-NULL count
assert count_field == expected_non_null, (
f"count(c2) should exclude NULL when queried together: {count_field} != {expected_non_null}"
)
# count(*) must be greater than count(nullable_field)
assert count_star > count_field, (
f"count(*) should be greater than count(nullable_field): {count_star} <= {count_field}"
)
log.info(f"Together: count(*) = {count_star}, count(c2) = {count_field}")
log.info("test_count_star_and_count_field_together passed")
@pytest.mark.tags(CaseLabel.L1)
def test_count_star_with_group_by(self):
"""
target: test count(*) works with GROUP BY after fix for #47326
method: query with group_by_fields=["c1"], output_fields=["c1", "count(*)"], limit=100
expected: returns correct count per group (count(*) includes NULL values)
"""
client = self._client()
# count(*) with GROUP BY + limit should now succeed
results, _ = self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, "count(*)"],
)
# Should return 7 groups (unique values of c1)
assert len(results) == 7, f"Expected 7 groups, got {len(results)}"
# Verify count(*) for each group against pandas ground truth
ground_truth = self.datas.groupby(self.c1_field_name).size().reset_index(name="count_star")
for result in results:
c1_value = result[self.c1_field_name]
expected_count = int(ground_truth[ground_truth[self.c1_field_name] == c1_value].iloc[0]["count_star"])
assert result["count(*)"] == expected_count, (
f"count(*) mismatch for c1={c1_value}: {result['count(*)']} != {expected_count}"
)
log.info("test_count_star_with_group_by passed")
@pytest.mark.tags(CaseLabel.L1)
def test_count_star_and_count_field_with_group_by(self):
"""
target: test count(*) and count(nullable_field) together with GROUP BY
method: query with group_by + output_fields=["c1", "count(*)", "count(c2)"]
expected: count(*) >= count(c2) for each group (c2 is nullable)
Note: Regression test combining #47509 and #47326 fixes
"""
client = self._client()
results, _ = self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, "count(*)", "count(c2)"],
)
assert len(results) == 7, f"Expected 7 groups, got {len(results)}"
# Ground truth from pandas
ground_truth = (
self.datas.groupby(self.c1_field_name)
.agg(count_star=(self.c1_field_name, "size"), count_c2=(self.c2_field_name, "count"))
.reset_index()
)
for result in results:
c1_value = result[self.c1_field_name]
gt = ground_truth[ground_truth[self.c1_field_name] == c1_value].iloc[0]
assert result["count(*)"] == int(gt["count_star"]), (
f"count(*) mismatch for c1={c1_value}: {result['count(*)']} != {gt['count_star']}"
)
assert result["count(c2)"] == int(gt["count_c2"]), (
f"count(c2) mismatch for c1={c1_value}: {result['count(c2)']} != {gt['count_c2']}"
)
# count(*) must be >= count(nullable_field)
assert result["count(*)"] >= result["count(c2)"], f"count(*) should be >= count(c2) for c1={c1_value}"
log.info("test_count_star_and_count_field_with_group_by passed")
@pytest.mark.tags(CaseLabel.L1)
def test_aggregation_function_case_insensitive(self):
"""
target: test that aggregation functions are case-insensitive
method: query with aggregation functions in different cases (COUNT, count, Count, etc.)
expected: all variations work and return same results
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field("pk", DataType.VARCHAR, is_primary=True, max_length=100)
schema.add_field("c1", DataType.VARCHAR, max_length=100)
schema.add_field("c2", DataType.INT32)
schema.add_field("c3", DataType.DOUBLE)
schema.add_field("vec", DataType.FLOAT_VECTOR, dim=8)
self.create_collection(client, collection_name, schema=schema)
# Insert test data
rows = [
{"pk": "pk_1", "c1": "A", "c2": 10, "c3": 1.5, "vec": [0.1] * 8},
{"pk": "pk_2", "c1": "A", "c2": 20, "c3": 2.5, "vec": [0.2] * 8},
{"pk": "pk_3", "c1": "B", "c2": 30, "c3": 3.5, "vec": [0.3] * 8},
]
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="vec", metric_type="L2", index_type="FLAT", params={})
self.create_index(client, collection_name, index_params=index_params)
self.load_collection(client, collection_name)
# Test different case variations
test_cases = [
# lowercase
["c1", "count(c2)", "sum(c2)", "min(c2)", "max(c2)", "avg(c3)"],
# UPPERCASE
["c1", "COUNT(c2)", "SUM(c2)", "MIN(c2)", "MAX(c2)", "AVG(c3)"],
# Mixed case
["c1", "Count(c2)", "Sum(c2)", "Min(c2)", "Max(c2)", "Avg(c3)"],
# Random mixed
["c1", "CoUnT(c2)", "sUm(c2)", "mIn(c2)", "MaX(c2)", "AvG(c3)"],
]
expected_group_a = {"c1": "A", "count": 2, "sum": 30, "min": 10, "max": 20, "avg": 2.0}
expected_group_b = {"c1": "B", "count": 1, "sum": 30, "min": 30, "max": 30, "avg": 3.5}
for idx, output_fields in enumerate(test_cases):
results, _ = self.query(
client, collection_name, filter="", limit=100, group_by_fields=["c1"], output_fields=output_fields
)
# Verify results
assert len(results) == 2, f"Test case {idx}: Expected 2 groups, got {len(results)}"
# Find groups A and B
group_a = [r for r in results if r["c1"] == "A"][0]
group_b = [r for r in results if r["c1"] == "B"][0]
# The result field names will match the case used in output_fields
count_key = output_fields[1] # e.g., "count(c2)" or "COUNT(c2)"
sum_key = output_fields[2]
min_key = output_fields[3]
max_key = output_fields[4]
avg_key = output_fields[5]
# Verify Group A
assert group_a[count_key] == expected_group_a["count"], f"Test case {idx}: Group A count mismatch"
assert group_a[sum_key] == expected_group_a["sum"], f"Test case {idx}: Group A sum mismatch"
assert group_a[min_key] == expected_group_a["min"], f"Test case {idx}: Group A min mismatch"
assert group_a[max_key] == expected_group_a["max"], f"Test case {idx}: Group A max mismatch"
assert abs(group_a[avg_key] - expected_group_a["avg"]) < 0.01, f"Test case {idx}: Group A avg mismatch"
# Verify Group B
assert group_b[count_key] == expected_group_b["count"], f"Test case {idx}: Group B count mismatch"
assert group_b[sum_key] == expected_group_b["sum"], f"Test case {idx}: Group B sum mismatch"
assert group_b[min_key] == expected_group_b["min"], f"Test case {idx}: Group B min mismatch"
assert group_b[max_key] == expected_group_b["max"], f"Test case {idx}: Group B max mismatch"
assert abs(group_b[avg_key] - expected_group_b["avg"]) < 0.01, f"Test case {idx}: Group B avg mismatch"
log.info(f"Test case {idx} with {output_fields[1]} passed")
self.drop_collection(client, collection_name)
log.info("test_aggregation_function_case_insensitive passed")
@pytest.mark.tags(CaseLabel.L1)
def test_group_by_int8_field(self):
"""
target: test GROUP BY on INT8 field with aggregation functions
method: query with group_by_fields=[INT8 field], apply COUNT and SUM aggregations
expected: return correct aggregation results for each INT8 group value, validate against pandas ground truth
"""
client = self._client()
# Query with GROUP BY on INT8 field
results, _ = self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c7_field_name],
output_fields=[self.c7_field_name, "count(c2)", "sum(c3)"],
)
# Verify against ground truth
ground_truth = (
self.datas.groupby(self.c7_field_name)
.agg(count_c2=(self.c2_field_name, "count"), sum_c3=(self.c3_field_name, "sum"))
.reset_index()
)
# Should have 5 groups (unique INT8 values: 1, 2, 3, 4, 5)
assert len(results) == 5, f"Expected 5 groups for INT8 field, got {len(results)}"
# Verify each group's aggregation values
for result in results:
c7_value = result[self.c7_field_name]
expected = ground_truth[ground_truth[self.c7_field_name] == c7_value].iloc[0]
assert result["count(c2)"] == expected["count_c2"], (
f"INT8 group {c7_value}: count mismatch, expected {expected['count_c2']}, got {result['count(c2)']}"
)
assert result["sum(c3)"] == expected["sum_c3"], (
f"INT8 group {c7_value}: sum mismatch, expected {expected['sum_c3']}, got {result['sum(c3)']}"
)
log.info("test_group_by_int8_field passed")
@pytest.mark.tags(CaseLabel.L1)
def test_group_by_int64_field(self):
"""
target: test GROUP BY on INT64 field (non-timestamp) with aggregation functions
method: query with group_by_fields=[INT64 field], apply COUNT and AVG aggregations
expected: return correct aggregation results for each INT64 group value, validate against pandas ground truth
"""
client = self._client()
# Query with GROUP BY on INT64 field
results, _ = self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c8_field_name],
output_fields=[self.c8_field_name, "count(c2)", "avg(c4)"],
)
# Verify against ground truth
ground_truth = (
self.datas.groupby(self.c8_field_name)
.agg(count_c2=(self.c2_field_name, "count"), avg_c4=(self.c4_field_name, "mean"))
.reset_index()
)
# Should have 5 groups (unique INT64 values: 100, 200, 300, 400, 500)
assert len(results) == 5, f"Expected 5 groups for INT64 field, got {len(results)}"
# Verify each group's aggregation values
for result in results:
c8_value = result[self.c8_field_name]
expected = ground_truth[ground_truth[self.c8_field_name] == c8_value].iloc[0]
assert result["count(c2)"] == expected["count_c2"], (
f"INT64 group {c8_value}: count mismatch, expected {expected['count_c2']}, got {result['count(c2)']}"
)
assert abs(result["avg(c4)"] - expected["avg_c4"]) < 0.01, (
f"INT64 group {c8_value}: avg mismatch, expected {expected['avg_c4']}, got {result['avg(c4)']}"
)
log.info("test_group_by_int64_field passed")
@pytest.mark.tags(CaseLabel.L1)
def test_group_by_field_not_required_in_output_fields(self):
"""
target: test GROUP BY field is not required in output_fields.
method: query with group_by_fields=["c1"] but output_fields=["count(c2)"] (missing c1)
expected: query succeeds and returns one aggregate row per group without projecting the group key.
Note: Milvus issue #47334 was closed as by-design. SQL allows SELECT count(*) FROM t GROUP BY category
without including category in the SELECT list.
"""
client = self._client()
results, _ = self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c1_field_name],
output_fields=["count(c2)"],
)
ground_truth_counts = (
self.datas.groupby(self.c1_field_name)
.agg(count_c2=(self.c2_field_name, "count"))
.reset_index()["count_c2"]
.tolist()
)
result_counts = [result["count(c2)"] for result in results]
assert len(results) == len(ground_truth_counts)
assert sorted(result_counts) == sorted(ground_truth_counts)
for result in results:
assert self.c1_field_name not in result
assert set(result.keys()) == {"count(c2)"}
log.info("test_group_by_field_not_required_in_output_fields passed")
@pytest.mark.tags(CaseLabel.L1)
def test_unsupported_vector_field(self):
"""
target: test error for Vector field in GROUP BY
method: query with group_by_fields=["vector_field"]
expected: raise error indicating Vector not supported
"""
client = self._client()
# Try to group by vector field - should fail with invalid parameter error
error = {
ct.err_code: 1100,
ct.err_msg: f"group by field {self.vector_field_name} has unsupported data type FloatVector",
}
self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.vector_field_name],
output_fields=[self.vector_field_name, "count(c1)"],
check_task=CheckTasks.err_res,
check_items=error,
)
log.info("test_unsupported_vector_field passed")
@pytest.mark.tags(CaseLabel.L1)
def test_unsupported_aggregation_function_varchar(self):
"""
target: test error for SUM on VarChar field
method: query with output_fields=["sum(varchar_field)"]
expected: raise error indicating VarChar doesn't support SUM
"""
client = self._client()
# Try SUM on VarChar field
error = {ct.err_code: 65535, ct.err_msg: "aggregation operator sum does not support data type VarChar"}
self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, f"sum({self.c6_field_name})"],
check_task=CheckTasks.err_res,
check_items=error,
)
log.info("test_unsupported_aggregation_function_varchar passed")
@pytest.mark.tags(CaseLabel.L1)
def test_mixed_aggregation_and_non_aggregation_fields(self):
"""
target: test error when output_fields mixes aggregation and non-aggregation fields
method: query with output_fields containing both regular field (not in group_by) and aggregation
expected: raise error indicating only group_by fields and aggregation fields allowed
"""
client = self._client()
# Try to mix regular field (c3) with aggregation - c3 is not in group_by_fields
error = {ct.err_code: 1100, ct.err_msg: "output field 'c3' is not allowed"}
self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c1_field_name],
output_fields=[
self.c1_field_name,
self.c3_field_name,
"count(c2)",
], # c3 is neither group_by field nor aggregation
check_task=CheckTasks.err_res,
check_items=error,
)
log.info("test_mixed_aggregation_and_non_aggregation_fields passed")
@pytest.mark.tags(CaseLabel.L1)
def test_invalid_aggregation_function_syntax(self):
"""
target: test error for invalid aggregation function syntax
method: query with invalid function names or syntax
expected: raise error indicating syntax error
Note: Milvus aggregation functions are case-insensitive (COUNT, count, Count all work)
"""
client = self._client()
# Test case 1: Unknown aggregation function
error = {ct.err_code: 1, ct.err_msg: ""}
self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, "median(c2)"], # Unsupported function
check_task=CheckTasks.err_res,
check_items=error,
)
# Test case 2: Malformed syntax (missing closing parenthesis)
self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, "count(c2"], # Missing closing parenthesis
check_task=CheckTasks.err_res,
check_items=error,
)
# Test case 3: Empty function call
self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c1_field_name],
output_fields=[self.c1_field_name, "count()"], # Empty parameter
check_task=CheckTasks.err_res,
check_items=error,
)
log.info("test_invalid_aggregation_function_syntax passed")
@pytest.mark.tags(CaseLabel.L1)
def test_unsupported_float_type_for_groupby(self):
"""
target: test error for FLOAT field in GROUP BY
method: query with group_by_fields=[FLOAT field]
expected: raise error indicating FLOAT type not supported for GROUP BY
Note: Floating point types are not suitable for GROUP BY due to precision issues
"""
client = self._client()
# Try to group by FLOAT field - should fail
error = {ct.err_code: 1100, ct.err_msg: f"group by field {self.c9_field_name} has unsupported data type Float"}
self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c9_field_name],
output_fields=[self.c9_field_name, "count(c1)"],
check_task=CheckTasks.err_res,
check_items=error,
)
log.info("test_unsupported_float_type_for_groupby passed")
@pytest.mark.tags(CaseLabel.L1)
def test_unsupported_double_type_for_groupby(self):
"""
target: test error for DOUBLE field in GROUP BY
method: query with group_by_fields=[DOUBLE field]
expected: raise error indicating DOUBLE type not supported for GROUP BY
Note: Floating point types are not suitable for GROUP BY due to precision issues
"""
client = self._client()
# Try to group by DOUBLE field - should fail
error = {ct.err_code: 1100, ct.err_msg: f"group by field {self.c4_field_name} has unsupported data type Double"}
self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c4_field_name],
output_fields=[self.c4_field_name, "count(c1)"],
check_task=CheckTasks.err_res,
check_items=error,
)
log.info("test_unsupported_double_type_for_groupby passed")
@pytest.mark.tags(CaseLabel.L1)
def test_group_by_nullable_varchar_field(self):
"""
target: test GROUP BY on nullable VARCHAR field returns actual group values (not all NULLs)
method: query with GROUP BY on c10_nullable_varchar (nullable, ~15% NULL)
expected: 1. non-NULL groups return actual VARCHAR values
2. NULL group returns None
3. aggregation values match pandas ground truth
verified fix for: issue #47350, PR #47445
"""
client = self._client()
results, _ = self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c10_field_name],
output_fields=[self.c10_field_name, "count(c3)", "sum(c3)"],
)
group_values = [r[self.c10_field_name] for r in results]
non_null_groups = sorted([v for v in group_values if v is not None])
null_groups = [v for v in group_values if v is None]
# Core assertion: non-NULL groups must return actual values (not all NULLs)
expected_groups = sorted(["P", "Q", "R", "S", "T_v", "U_v", "V_v"])
assert non_null_groups == expected_groups, (
f"Expected groups {expected_groups}, got {non_null_groups}. Bug #47350 may not be fixed!"
)
assert len(null_groups) == 1, f"Expected exactly 1 NULL group, got {len(null_groups)}"
# Verify aggregation values against pandas ground truth
ground_truth = (
self.datas.groupby(self.c10_field_name)
.agg(count_c3=(self.c3_field_name, "count"), sum_c3=(self.c3_field_name, "sum"))
.reset_index()
)
for result in results:
if result[self.c10_field_name] is None:
continue
expected = ground_truth[ground_truth[self.c10_field_name] == result[self.c10_field_name]].iloc[0]
assert result["count(c3)"] == expected["count_c3"], (
f"COUNT mismatch for {self.c10_field_name}={result[self.c10_field_name]}"
)
assert result["sum(c3)"] == expected["sum_c3"], (
f"SUM mismatch for {self.c10_field_name}={result[self.c10_field_name]}"
)
log.info("test_group_by_nullable_varchar_field passed")
@pytest.mark.tags(CaseLabel.L1)
def test_group_by_nullable_int16_field(self):
"""
target: test GROUP BY on nullable INT16 field returns actual group values
method: query with GROUP BY on c11_nullable_int16 (nullable, ~15% NULL)
expected: 1. non-NULL groups return actual INT16 values
2. NULL group returns None
3. aggregation values match pandas ground truth
verified fix for: issue #47350, PR #47445
"""
client = self._client()
results, _ = self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c11_field_name],
output_fields=[self.c11_field_name, "count(c3)", "sum(c3)"],
)
group_values = [r[self.c11_field_name] for r in results]
non_null_groups = sorted([v for v in group_values if v is not None])
null_groups = [v for v in group_values if v is None]
expected_groups = sorted([10, 20, 30, 40, 50])
assert non_null_groups == expected_groups, (
f"Expected INT16 groups {expected_groups}, got {non_null_groups}. Bug #47350 may not be fixed!"
)
assert len(null_groups) == 1, f"Expected 1 NULL group, got {len(null_groups)}"
# Verify aggregation against pandas
ground_truth = (
self.datas.groupby(self.c11_field_name)
.agg(count_c3=(self.c3_field_name, "count"), sum_c3=(self.c3_field_name, "sum"))
.reset_index()
)
for result in results:
if result[self.c11_field_name] is None:
continue
expected = ground_truth[ground_truth[self.c11_field_name] == result[self.c11_field_name]].iloc[0]
assert result["count(c3)"] == expected["count_c3"], (
f"COUNT mismatch for {self.c11_field_name}={result[self.c11_field_name]}"
)
assert result["sum(c3)"] == expected["sum_c3"], (
f"SUM mismatch for {self.c11_field_name}={result[self.c11_field_name]}"
)
log.info("test_group_by_nullable_int16_field passed")
@pytest.mark.tags(CaseLabel.L1)
def test_multi_column_group_by_with_nullable_field(self):
"""
target: test multi-column GROUP BY where one column is nullable
method: GROUP BY [c1 (non-nullable VARCHAR), c10_nullable_varchar (nullable VARCHAR)]
expected: 1. non-nullable column always has actual values
2. nullable column returns actual values for non-NULL groups and None for NULL group
3. aggregation values correct
verified fix for: issue #47350, PR #47445
"""
client = self._client()
results, _ = self.query(
client,
self.collection_name,
filter="",
limit=100,
group_by_fields=[self.c1_field_name, self.c10_field_name],
output_fields=[self.c1_field_name, self.c10_field_name, "count(c3)", "sum(c3)"],
)
assert len(results) > 0, "Expected at least 1 group"
expected_c1_values = {"A", "B", "C", "D", "E", "F", "G"}
expected_c10_values = {"P", "Q", "R", "S", "T_v", "U_v", "V_v"}
# Verify non-nullable column always has actual values
for r in results:
assert r[self.c1_field_name] in expected_c1_values, (
f"Non-nullable GROUP BY field '{self.c1_field_name}' has unexpected value: {r[self.c1_field_name]}"
)
# Verify nullable column has actual values for non-NULL groups
c10_values = [r[self.c10_field_name] for r in results]
non_null_c10 = set(v for v in c10_values if v is not None)
has_null_c10 = None in c10_values
assert non_null_c10 == expected_c10_values, f"Expected c10 values {expected_c10_values}, got {non_null_c10}"
assert has_null_c10, "Expected at least one NULL group for nullable c10 since ~15% of data is NULL"
# Verify aggregation against pandas (for non-null c10 groups only)
non_null_df = self.datas[self.datas[self.c10_field_name].notna()]
ground_truth = (
non_null_df.groupby([self.c1_field_name, self.c10_field_name])
.agg(count_c3=(self.c3_field_name, "count"), sum_c3=(self.c3_field_name, "sum"))
.reset_index()
)
for result in results:
if result[self.c10_field_name] is None:
continue
mask = (ground_truth[self.c1_field_name] == result[self.c1_field_name]) & (
ground_truth[self.c10_field_name] == result[self.c10_field_name]
)
if mask.sum() == 0:
continue
expected = ground_truth[mask].iloc[0]
assert result["count(c3)"] == expected["count_c3"], (
f"COUNT mismatch for c1={result[self.c1_field_name]}, c10={result[self.c10_field_name]}"
)
assert result["sum(c3)"] == expected["sum_c3"], (
f"SUM mismatch for c1={result[self.c1_field_name]}, c10={result[self.c10_field_name]}"
)
log.info("test_multi_column_group_by_with_nullable_field passed")
@pytest.mark.tags(CaseLabel.L1)
def test_search_group_by_nullable_field(self):
"""
target: test search with GROUP BY on nullable field returns actual group values
method: search with group_by_field=c10_nullable_varchar on the shared collection
expected: 1. search results have actual group values (not all NULLs)
2. each group appears at most once
verified fix for: issue #47350, PR #47445
"""
client = self._client()
search_vec = [[float(x) for x in np.random.random(8)]]
search_res = self.search(
client,
self.collection_name,
data=search_vec,
limit=20,
group_by_field=self.c10_field_name,
output_fields=[self.c10_field_name],
search_params={"metric_type": "L2"},
)[0]
# search_res is SearchResult; search_res[0] is Hits for nq=0
hits = search_res[0]
group_values = [hit.fields.get(self.c10_field_name) for hit in hits]
non_null_groups = [v for v in group_values if v is not None]
# Core assertion: search GROUP BY returns actual values
assert len(non_null_groups) > 0, "All search GROUP BY values are NULL - bug #47350 may not be fixed!"
# Verify actual group values are from expected set
expected_groups = {"P", "Q", "R", "S", "T_v", "U_v", "V_v"}
for v in non_null_groups:
assert v in expected_groups, f"Unexpected group value: {v}"
# Verify group uniqueness (each group appears at most once)
assert len(group_values) == len(set(str(v) for v in group_values)), (
f"Duplicate group values in search results: {group_values}"
)
log.info("test_search_group_by_nullable_field passed")
@pytest.mark.xdist_group("TestQueryAggregationIndependentV2")
class TestQueryAggregationIndependentV2(TestMilvusClientV2Base):
"""
Test Query Aggregation scenarios requiring special schemas
These tests need independent collections because they require:
- JSON fields
- Array fields
Note: Nullable field aggregation is now covered by the shared collection tests.
"""
@pytest.mark.tags(CaseLabel.L1)
def test_unsupported_json_type(self):
"""
target: test error for JSON field in GROUP BY or aggregation
method: query with JSON field in group_by_fields or aggregation functions
expected: raise error indicating JSON type not supported
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field("pk", DataType.VARCHAR, is_primary=True, max_length=100)
schema.add_field("c1", DataType.VARCHAR, max_length=100)
schema.add_field("json_field", DataType.JSON)
schema.add_field("vec", DataType.FLOAT_VECTOR, dim=8)
self.create_collection(client, collection_name, schema=schema)
rows = [{"pk": "pk_1", "c1": "A", "json_field": {"key": "value"}, "vec": [0.1] * 8}]
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="vec", metric_type="L2", index_type="FLAT", params={})
self.create_index(client, collection_name, index_params=index_params)
self.load_collection(client, collection_name)
# Try to group by JSON field
error = {ct.err_code: 1100, ct.err_msg: "group by field json_field has unsupported data type JSON"}
self.query(
client,
collection_name,
filter="",
limit=100,
group_by_fields=["json_field"],
output_fields=["json_field", "count(c1)"],
check_task=CheckTasks.err_res,
check_items=error,
)
self.drop_collection(client, collection_name)
log.info("test_unsupported_json_type passed")
@pytest.mark.tags(CaseLabel.L1)
def test_unsupported_array_type(self):
"""
target: test error for Array field in GROUP BY or aggregation
method: query with Array field in group_by_fields or aggregation functions
expected: raise error indicating Array type not supported
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field("pk", DataType.VARCHAR, is_primary=True, max_length=100)
schema.add_field("c1", DataType.VARCHAR, max_length=100)
schema.add_field("array_field", DataType.ARRAY, element_type=DataType.INT64, max_capacity=10)
schema.add_field("vec", DataType.FLOAT_VECTOR, dim=8)
self.create_collection(client, collection_name, schema=schema)
rows = [{"pk": "pk_1", "c1": "A", "array_field": [1, 2, 3], "vec": [0.1] * 8}]
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="vec", metric_type="L2", index_type="FLAT", params={})
self.create_index(client, collection_name, index_params=index_params)
self.load_collection(client, collection_name)
# Try to group by Array field
error = {ct.err_code: 1100, ct.err_msg: "group by field array_field has unsupported data type Array"}
self.query(
client,
collection_name,
filter="",
limit=100,
group_by_fields=["array_field"],
output_fields=["array_field", "count(c1)"],
check_task=CheckTasks.err_res,
check_items=error,
)
self.drop_collection(client, collection_name)
log.info("test_unsupported_array_type passed")
@pytest.mark.tags(CaseLabel.L1)
def test_high_cardinality_group_by(self):
"""
target: test GROUP BY on high-cardinality field (>2000 unique values)
on both growing segment (before flush) and sealed segment (after flush)
method: 1. insert 3000 rows with 2500 unique group keys
2. query GROUP BY before flush (growing segment)
3. flush, then query GROUP BY again (sealed segment)
4. verify correctness in both cases
expected: returns correct number of unique groups with correct aggregation values
verified fix for: issue #47569 (HashTable slots overflow)
Note: Original HashTable had fixed 2048 slots with 7/8 load factor,
limiting GROUP BY to at most 1792 unique groups before crash.
PR #48174 added dynamic rehash to fix this.
"""
client = self._client()
collection_name = cf.gen_unique_str(prefix)
schema = self.create_schema(client, enable_dynamic_field=False)[0]
schema.add_field("pk", DataType.INT64, is_primary=True, auto_id=True)
schema.add_field("high_card", DataType.INT64)
schema.add_field("value", DataType.INT64)
schema.add_field("vec", DataType.FLOAT_VECTOR, dim=8)
self.create_collection(client, collection_name, schema=schema)
np.random.seed(19530)
nb = 3000
num_unique = 2500 # well above the original 1792 limit
rows = []
for i in range(nb):
rows.append(
{
"high_card": int(np.random.randint(0, num_unique)),
"value": int(np.random.randint(1, 100)),
"vec": [float(x) for x in np.random.random(8)],
}
)
self.insert(client, collection_name, data=rows)
# NO flush yet — data in growing segment
index_params = self.prepare_index_params(client)[0]
index_params.add_index(field_name="vec", metric_type="L2", index_type="FLAT", params={})
self.create_index(client, collection_name, index_params=index_params)
self.load_collection(client, collection_name)
df = pd.DataFrame(rows)
expected_groups = df["high_card"].nunique()
ground_truth = df.groupby("high_card").agg(count_val=("value", "count"), sum_val=("value", "sum")).reset_index()
# Phase 1: query on growing segment (before flush)
results, _ = self.query(
client,
collection_name,
filter="",
limit=num_unique + 100,
group_by_fields=["high_card"],
output_fields=["high_card", "count(value)", "sum(value)"],
)
group_keys = [r["high_card"] for r in results]
assert len(group_keys) == len(set(group_keys)), "Duplicate group keys on growing segment"
assert len(results) == expected_groups, (
f"Growing segment: expected {expected_groups} groups, got {len(results)}"
)
log.info(f"Growing segment: {len(results)} groups, all unique")
# Phase 2: flush and query on sealed segment
self.flush(client, collection_name)
results, _ = self.query(
client,
collection_name,
filter="",
limit=num_unique + 100,
group_by_fields=["high_card"],
output_fields=["high_card", "count(value)", "sum(value)"],
)
group_keys = [r["high_card"] for r in results]
assert len(group_keys) == len(set(group_keys)), "Duplicate group keys on sealed segment"
assert len(results) == expected_groups, f"Sealed segment: expected {expected_groups} groups, got {len(results)}"
# Verify aggregation correctness (spot check first 50)
for result in results[:50]:
hk = result["high_card"]
expected = ground_truth[ground_truth["high_card"] == hk].iloc[0]
assert result["count(value)"] == expected["count_val"], f"COUNT mismatch for high_card={hk}"
assert result["sum(value)"] == expected["sum_val"], f"SUM mismatch for high_card={hk}"
self.drop_collection(client, collection_name)
log.info(
f"test_high_cardinality_group_by passed: {len(results)} groups verified on both growing and sealed segments"
)