feat: impl StructArray -- support index access (#48987)

issue: https://github.com/milvus-io/milvus/issues/42148
design doc:
https://github.com/milvus-io/milvus-design-docs/blob/main/design_docs/20260306-struct.md

---------

Signed-off-by: SpadeA <tangchenjie1210@gmail.com>
This commit is contained in:
Spade A
2026-04-16 18:01:42 +08:00
committed by GitHub
parent 0fb4ce552f
commit 7d0b8df0c3
11 changed files with 1558 additions and 1234 deletions
+4 -2
View File
@@ -10,6 +10,7 @@ expr:
| (Identifier|Meta) # Identifier
| JSONIdentifier # JSONIdentifier
| StructFieldIdentifier # StructField
| StructIndexFieldIdentifier # StructIndexField
| StructSubFieldIdentifier # StructSubField
| LBRACE Identifier RBRACE # TemplateVariable
| '(' expr ')' # Parens
@@ -38,8 +39,8 @@ expr:
| STIsValid'('Identifier')' # STIsValid
| ArrayLength'('(Identifier | JSONIdentifier | StructFieldIdentifier)')' # ArrayLength
| Identifier '(' ( expr (',' expr )* ','? )? ')' # Call
| expr op1 = (LT | LE) (Identifier | JSONIdentifier | StructSubFieldIdentifier) op2 = (LT | LE) expr # Range
| expr op1 = (GT | GE) (Identifier | JSONIdentifier | StructSubFieldIdentifier) op2 = (GT | GE) expr # ReverseRange
| expr op1 = (LT | LE) (Identifier | JSONIdentifier | StructSubFieldIdentifier | StructIndexFieldIdentifier) op2 = (LT | LE) expr # Range
| expr op1 = (GT | GE) (Identifier | JSONIdentifier | StructSubFieldIdentifier | StructIndexFieldIdentifier) op2 = (GT | GE) expr # ReverseRange
| expr op = (LT | LE | GT | GE) expr # Relational
| expr op = (EQ | NE) expr # Equality
| expr BAND expr # BitAnd
@@ -140,6 +141,7 @@ Meta: '$meta';
StringLiteral: EncodingPrefix? ('"' DoubleSCharSequence? '"' | '\'' SingleSCharSequence? '\'');
JSONIdentifier: (Identifier | Meta)('[' (StringLiteral | DecimalConstant) ']')+;
StructIndexFieldIdentifier: Identifier '[' DecimalConstant ']' '[' Identifier ']';
StructFieldIdentifier: Identifier '[' Identifier ']';
StructSubFieldIdentifier: '$[' Identifier ']';
File diff suppressed because one or more lines are too long
@@ -69,10 +69,11 @@ Identifier=68
Meta=69
StringLiteral=70
JSONIdentifier=71
StructFieldIdentifier=72
StructSubFieldIdentifier=73
Whitespace=74
Newline=75
StructIndexFieldIdentifier=72
StructFieldIdentifier=73
StructSubFieldIdentifier=74
Whitespace=75
Newline=76
'('=1
')'=2
'['=3
File diff suppressed because one or more lines are too long
@@ -69,10 +69,11 @@ Identifier=68
Meta=69
StringLiteral=70
JSONIdentifier=71
StructFieldIdentifier=72
StructSubFieldIdentifier=73
Whitespace=74
Newline=75
StructIndexFieldIdentifier=72
StructFieldIdentifier=73
StructSubFieldIdentifier=74
Whitespace=75
Newline=76
'('=1
')'=2
'['=3
@@ -183,6 +183,10 @@ func (v *BasePlanVisitor) VisitBitAnd(ctx *BitAndContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BasePlanVisitor) VisitStructIndexField(ctx *StructIndexFieldContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BasePlanVisitor) VisitIsNull(ctx *IsNullContext) interface{} {
return v.VisitChildren(ctx)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -139,6 +139,9 @@ type PlanVisitor interface {
// Visit a parse tree produced by PlanParser#BitAnd.
VisitBitAnd(ctx *BitAndContext) interface{}
// Visit a parse tree produced by PlanParser#StructIndexField.
VisitStructIndexField(ctx *StructIndexFieldContext) interface{}
// Visit a parse tree produced by PlanParser#IsNull.
VisitIsNull(ctx *IsNullContext) interface{}
+67 -10
View File
@@ -665,10 +665,6 @@ func isElementFilterExpr(expr *ExprWithType) bool {
return expr.expr.GetElementFilterExpr() != nil
}
func isMatchExpr(expr *ExprWithType) bool {
return expr.expr.GetMatchExpr() != nil
}
const EPSILON = 1e-10
func (v *ParserVisitor) VisitRandomSample(ctx *parser.RandomSampleContext) interface{} {
@@ -849,7 +845,32 @@ func (v *ParserVisitor) getColumnInfoFromStructSubField(tokenText string) (*plan
}, nil
}
func (v *ParserVisitor) getChildColumnInfo(identifier, child, structSubField antlr.TerminalNode) (*planpb.ColumnInfo, error) {
func (v *ParserVisitor) getColumnInfoFromStructIndexField(identifier string) (*planpb.ColumnInfo, error) {
// Parse "struct_arr[0][sub_field]" -> fieldName="struct_arr", index="0", subField="sub_field"
parts := strings.SplitN(identifier, "[", 3)
if len(parts) != 3 {
return nil, merr.WrapErrParameterInvalidMsg("invalid struct index field identifier: %s", identifier)
}
fieldName := parts[0]
index := strings.TrimSuffix(parts[1], "]")
subField := strings.TrimSuffix(parts[2], "]")
structFieldName := fieldName + "[" + subField + "]"
field, err := v.schema.GetFieldFromName(structFieldName)
if err != nil {
return nil, merr.WrapErrParameterInvalidMsg("struct field not found: %s, error: %s", structFieldName, err)
}
return &planpb.ColumnInfo{
FieldId: field.FieldID,
DataType: field.DataType,
NestedPath: []string{index},
ElementType: field.GetElementType(),
}, nil
}
func (v *ParserVisitor) getChildColumnInfo(identifier, child, structSubField, structIndexField antlr.TerminalNode) (*planpb.ColumnInfo, error) {
if identifier != nil {
childExpr, err := v.translateIdentifier(identifier.GetText())
if err != nil {
@@ -862,6 +883,10 @@ func (v *ParserVisitor) getChildColumnInfo(identifier, child, structSubField ant
return v.getColumnInfoFromStructSubField(structSubField.GetText())
}
if structIndexField != nil {
return v.getColumnInfoFromStructIndexField(structIndexField.GetText())
}
return v.getColumnInfoFromJSONIdentifier(child.GetText())
}
@@ -892,7 +917,12 @@ func (v *ParserVisitor) VisitCall(ctx *parser.CallContext) interface{} {
// VisitRange translates expr to range plan.
func (v *ParserVisitor) VisitRange(ctx *parser.RangeContext) interface{} {
columnInfo, err := v.getChildColumnInfo(ctx.Identifier(), ctx.JSONIdentifier(), ctx.StructSubFieldIdentifier())
columnInfo, err := v.getChildColumnInfo(
ctx.Identifier(),
ctx.JSONIdentifier(),
ctx.StructSubFieldIdentifier(),
ctx.StructIndexFieldIdentifier(),
)
if err != nil {
return err
}
@@ -973,7 +1003,12 @@ func (v *ParserVisitor) VisitRange(ctx *parser.RangeContext) interface{} {
// VisitReverseRange parses the expression like "1 > a > 0".
func (v *ParserVisitor) VisitReverseRange(ctx *parser.ReverseRangeContext) interface{} {
columnInfo, err := v.getChildColumnInfo(ctx.Identifier(), ctx.JSONIdentifier(), ctx.StructSubFieldIdentifier())
columnInfo, err := v.getChildColumnInfo(
ctx.Identifier(),
ctx.JSONIdentifier(),
ctx.StructSubFieldIdentifier(),
ctx.StructIndexFieldIdentifier(),
)
if err != nil {
return err
}
@@ -1431,6 +1466,28 @@ func (v *ParserVisitor) VisitStructField(ctx *parser.StructFieldContext) interfa
}
}
// VisitStructIndexField handles struct_arr[index][sub_field] syntax for accessing
// a specific element's sub-field in a struct array.
func (v *ParserVisitor) VisitStructIndexField(ctx *parser.StructIndexFieldContext) interface{} {
identifier := ctx.StructIndexFieldIdentifier().GetText()
columnInfo, err := v.getColumnInfoFromStructIndexField(identifier)
if err != nil {
return err
}
return &ExprWithType{
expr: &planpb.Expr{
Expr: &planpb.Expr_ColumnExpr{
ColumnExpr: &planpb.ColumnExpr{
Info: columnInfo,
},
},
},
dataType: columnInfo.GetDataType(),
nodeDependent: true,
}
}
func (v *ParserVisitor) VisitExists(ctx *parser.ExistsContext) interface{} {
child := ctx.Expr().Accept(v)
if err := getError(child); err != nil {
@@ -1538,7 +1595,7 @@ func (v *ParserVisitor) VisitEmptyArray(ctx *parser.EmptyArrayContext) interface
}
func (v *ParserVisitor) VisitIsNotNull(ctx *parser.IsNotNullContext) interface{} {
column, err := v.getChildColumnInfo(ctx.Identifier(), ctx.JSONIdentifier(), nil)
column, err := v.getChildColumnInfo(ctx.Identifier(), ctx.JSONIdentifier(), nil, nil)
if err != nil {
return err
}
@@ -1582,7 +1639,7 @@ func (v *ParserVisitor) VisitIsNotNull(ctx *parser.IsNotNullContext) interface{}
}
func (v *ParserVisitor) VisitIsNull(ctx *parser.IsNullContext) interface{} {
column, err := v.getChildColumnInfo(ctx.Identifier(), ctx.JSONIdentifier(), nil)
column, err := v.getChildColumnInfo(ctx.Identifier(), ctx.JSONIdentifier(), nil, nil)
if err != nil {
return err
}
@@ -1804,7 +1861,7 @@ func (v *ParserVisitor) VisitArrayLength(ctx *parser.ArrayLengthContext) interfa
ElementType: field.GetElementType(),
}
} else {
columnInfo, err = v.getChildColumnInfo(ctx.Identifier(), ctx.JSONIdentifier(), nil)
columnInfo, err = v.getChildColumnInfo(ctx.Identifier(), ctx.JSONIdentifier(), nil, nil)
if err != nil {
return err
}
@@ -2740,6 +2740,194 @@ func TestExpr_ArrayContains(t *testing.T) {
}
}
func TestExpr_StructIndexField(t *testing.T) {
schema := newTestSchema(true)
helper, err := typeutil.CreateSchemaHelper(schema)
assert.NoError(t, err)
// Valid struct_arr[index][sub_field] expressions
validExprs := []string{
// comparison
`struct_array[0][sub_int] > 50`,
`struct_array[0][sub_int] == 100`,
`struct_array[1][sub_str] == "apple"`,
`struct_array[0][sub_str] != "banana"`,
// range via &&
`struct_array[0][sub_int] >= 30 && struct_array[0][sub_int] <= 70`,
// IN / NOT IN
`struct_array[0][sub_int] in [10, 20, 30, 40, 50]`,
`struct_array[0][sub_int] not in [0, 1, 2, 3]`,
`struct_array[0][sub_str] in ["apple", "banana"]`,
`struct_array[0][sub_str] not in ["apple", "banana"]`,
}
for _, expr := range validExprs {
assertValidExpr(t, helper, expr)
}
// Invalid expressions
invalidExprs := []string{
// non-existent parent or sub-field
`non_existent[0][sub_int] > 50`,
`struct_array[0][non_existent] > 50`,
// unsupported form: sub-field first then index
`struct_array[sub_int][0] > 50`,
`struct_array[sub_str][0] == "apple"`,
`struct_array[sub_int][0] in [10, 20, 30]`,
}
for _, expr := range invalidExprs {
assertInvalidExpr(t, helper, expr)
}
}
func TestExpr_StructIndexField_PlanShape(t *testing.T) {
schema := newTestSchemaHelper(t)
plan, err := CreateSearchPlan(schema, `struct_array[0][sub_int] == 100`, "FloatVectorField", &planpb.QueryInfo{
Topk: 0,
MetricType: "",
SearchParams: "",
RoundDecimal: 0,
}, nil, nil)
require.NoError(t, err)
predicates := plan.GetVectorAnns().GetPredicates()
require.NotNil(t, predicates)
ure := predicates.GetUnaryRangeExpr()
require.NotNil(t, ure)
assert.Equal(t, planpb.OpType_Equal, ure.GetOp())
assert.Equal(t, int64(100), ure.GetValue().GetInt64Val())
columnInfo := ure.GetColumnInfo()
require.NotNil(t, columnInfo)
assert.Equal(t, int64(134), columnInfo.GetFieldId())
assert.Equal(t, schemapb.DataType_Array, columnInfo.GetDataType())
assert.Equal(t, schemapb.DataType_Int32, columnInfo.GetElementType())
assert.Equal(t, []string{"0"}, columnInfo.GetNestedPath())
ret := handleExpr(schema, `struct_array[1][sub_str] in ["apple", "banana"]`)
ewt, ok := ret.(*ExprWithType)
require.True(t, ok, "handleExpr should return *ExprWithType for term expression")
te := ewt.expr.GetTermExpr()
require.NotNil(t, te)
assert.Len(t, te.GetValues(), 2)
assert.Equal(t, "apple", te.GetValues()[0].GetStringVal())
assert.Equal(t, "banana", te.GetValues()[1].GetStringVal())
columnInfo = te.GetColumnInfo()
require.NotNil(t, columnInfo)
assert.Equal(t, int64(133), columnInfo.GetFieldId())
assert.Equal(t, schemapb.DataType_Array, columnInfo.GetDataType())
assert.Equal(t, schemapb.DataType_VarChar, columnInfo.GetElementType())
assert.Equal(t, []string{"1"}, columnInfo.GetNestedPath())
}
func TestExpr_StructFieldArrayLength(t *testing.T) {
schema := newTestSchemaHelper(t)
ret := handleExpr(schema, `array_length(struct_array[sub_int])`)
ewt, ok := ret.(*ExprWithType)
require.True(t, ok, "handleExpr should return *ExprWithType")
assert.Equal(t, schemapb.DataType_Int64, ewt.dataType)
bae := ewt.expr.GetBinaryArithExpr()
require.NotNil(t, bae)
assert.Equal(t, planpb.ArithOpType_ArrayLength, bae.GetOp())
require.Nil(t, bae.GetRight())
columnInfo := bae.GetLeft().GetColumnExpr().GetInfo()
require.NotNil(t, columnInfo)
assert.Equal(t, int64(134), columnInfo.GetFieldId())
assert.Equal(t, schemapb.DataType_Array, columnInfo.GetDataType())
assert.Equal(t, schemapb.DataType_Int32, columnInfo.GetElementType())
assert.Empty(t, columnInfo.GetNestedPath())
validExprs := []string{
`array_length(struct_array[sub_int]) == 10`,
`array_length(struct_array[sub_str]) > 0`,
}
for _, expr := range validExprs {
_, err := CreateSearchPlan(schema, expr, "FloatVectorField", &planpb.QueryInfo{
Topk: 0,
MetricType: "",
SearchParams: "",
RoundDecimal: 0,
}, nil, nil)
assert.NoError(t, err, expr)
}
}
func TestExpr_StructIndexField_RangeForms(t *testing.T) {
schema := newTestSchemaHelper(t)
testCases := []struct {
expr string
lower int64
upper int64
lowerInclusive bool
upperInclusive bool
}{
{
expr: `1 < struct_array[0][sub_int] < 3`,
lower: 1,
upper: 3,
lowerInclusive: false,
upperInclusive: false,
},
{
expr: `0 <= struct_array[0][sub_int] <= 100`,
lower: 0,
upper: 100,
lowerInclusive: true,
upperInclusive: true,
},
{
expr: `100 > struct_array[0][sub_int] > 0`,
lower: 0,
upper: 100,
lowerInclusive: false,
upperInclusive: false,
},
{
expr: `100 >= struct_array[0][sub_int] >= 0`,
lower: 0,
upper: 100,
lowerInclusive: true,
upperInclusive: true,
},
}
for _, tc := range testCases {
plan, err := CreateSearchPlan(schema, tc.expr, "FloatVectorField", &planpb.QueryInfo{
Topk: 0,
MetricType: "",
SearchParams: "",
RoundDecimal: 0,
}, nil, nil)
require.NoError(t, err, tc.expr)
predicates := plan.GetVectorAnns().GetPredicates()
require.NotNil(t, predicates, tc.expr)
bre := predicates.GetBinaryRangeExpr()
require.NotNil(t, bre, tc.expr)
assert.Equal(t, tc.lower, bre.GetLowerValue().GetInt64Val(), tc.expr)
assert.Equal(t, tc.upper, bre.GetUpperValue().GetInt64Val(), tc.expr)
assert.Equal(t, tc.lowerInclusive, bre.GetLowerInclusive(), tc.expr)
assert.Equal(t, tc.upperInclusive, bre.GetUpperInclusive(), tc.expr)
columnInfo := bre.GetColumnInfo()
require.NotNil(t, columnInfo, tc.expr)
assert.Equal(t, int64(134), columnInfo.GetFieldId(), tc.expr)
assert.Equal(t, schemapb.DataType_Array, columnInfo.GetDataType(), tc.expr)
assert.Equal(t, schemapb.DataType_Int32, columnInfo.GetElementType(), tc.expr)
assert.Equal(t, []string{"0"}, columnInfo.GetNestedPath(), tc.expr)
}
}
// ============================================================================
// Timestamptz Expression Tests
// These tests cover VisitTimestamptzCompareForward and VisitTimestamptzCompareReverse