mirror of
https://github.com/milvus-io/milvus.git
synced 2026-07-21 10:15:43 +00:00
fix: preserve nullable expr rewrite semantics (#50555)
issue: #49891 --------- Signed-off-by: Buqian Zheng <zhengbuqian@gmail.com>
This commit is contained in:
@@ -1151,6 +1151,11 @@ func (v *ParserVisitor) getColumnInfoFromStructSubField(tokenText string) (*plan
|
||||
// In element-level context, data_type should be the element type
|
||||
elementType := field.GetElementType()
|
||||
|
||||
nullable := field.GetNullable()
|
||||
if structField := v.schema.GetStructArrayFieldFromName(v.currentStructArrayField); structField != nil {
|
||||
nullable = nullable || structField.GetNullable()
|
||||
}
|
||||
|
||||
return &planpb.ColumnInfo{
|
||||
FieldId: field.FieldID,
|
||||
DataType: elementType, // Use element type, not storage type
|
||||
@@ -1159,7 +1164,7 @@ func (v *ParserVisitor) getColumnInfoFromStructSubField(tokenText string) (*plan
|
||||
IsPartitionKey: field.IsPartitionKey,
|
||||
IsClusteringKey: field.IsClusteringKey,
|
||||
ElementType: elementType,
|
||||
Nullable: field.GetNullable(),
|
||||
Nullable: nullable,
|
||||
IsElementLevel: true, // Mark as element-level access
|
||||
}, nil
|
||||
}
|
||||
@@ -1181,11 +1186,17 @@ func (v *ParserVisitor) getColumnInfoFromStructIndexField(identifier string) (*p
|
||||
return nil, merr.WrapErrParameterInvalidMsg("struct field not found: %s, error: %s", structFieldName, err)
|
||||
}
|
||||
|
||||
nullable := field.GetNullable()
|
||||
if structField := v.schema.GetStructArrayFieldFromName(fieldName); structField != nil {
|
||||
nullable = nullable || structField.GetNullable()
|
||||
}
|
||||
|
||||
return &planpb.ColumnInfo{
|
||||
FieldId: field.FieldID,
|
||||
DataType: field.DataType,
|
||||
NestedPath: []string{index},
|
||||
ElementType: field.GetElementType(),
|
||||
Nullable: nullable,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1815,6 +1826,7 @@ func (v *ParserVisitor) getColumnInfoFromJSONIdentifier(identifier string) (*pla
|
||||
DataType: field.DataType,
|
||||
NestedPath: nestedPath,
|
||||
ElementType: field.GetElementType(),
|
||||
Nullable: field.GetNullable(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1832,6 +1844,7 @@ func (v *ParserVisitor) VisitJSONIdentifier(ctx *parser.JSONIdentifierContext) i
|
||||
DataType: field.GetDataType(),
|
||||
NestedPath: field.GetNestedPath(),
|
||||
ElementType: field.GetElementType(),
|
||||
Nullable: field.GetNullable(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2734,6 +2747,10 @@ func (v *ParserVisitor) VisitStructSubField(ctx *parser.StructSubFieldContext) i
|
||||
|
||||
// In element-level context, use Array as storage type, element type for operations
|
||||
elementType := field.GetElementType()
|
||||
nullable := field.GetNullable()
|
||||
if structField := v.schema.GetStructArrayFieldFromName(v.currentStructArrayField); structField != nil {
|
||||
nullable = nullable || structField.GetNullable()
|
||||
}
|
||||
|
||||
return &ExprWithType{
|
||||
expr: &planpb.Expr{
|
||||
@@ -2747,7 +2764,7 @@ func (v *ParserVisitor) VisitStructSubField(ctx *parser.StructSubFieldContext) i
|
||||
IsPartitionKey: field.IsPartitionKey,
|
||||
IsClusteringKey: field.IsClusteringKey,
|
||||
ElementType: elementType, // Element type for operations
|
||||
Nullable: field.GetNullable(),
|
||||
Nullable: nullable,
|
||||
IsElementLevel: true, // Mark as element-level access
|
||||
},
|
||||
},
|
||||
|
||||
@@ -92,6 +92,7 @@ The rewriter can be configured via the following parameter (refreshable at runti
|
||||
- Rewrite runs after template value filling; template placeholders do not appear here.
|
||||
- Sorting/dedup for IN/NOT IN is deterministic; duplicates are removed post-sort.
|
||||
- Numeric-threshold for OR→IN / AND≠→NOT IN is defined in `util.go` (`defaultConvertOrToInNumericLimit`, default 150).
|
||||
- Nullable fields keep contradiction/tautology predicates instead of folding to valid `true`/`false`, because NULL must remain unknown under outer logical operators such as `NOT`. Fixed JSON/array paths also avoid domain-wide folds that assume every path/index exists.
|
||||
|
||||
### Pass Ordering (current)
|
||||
- OR branch:
|
||||
@@ -134,5 +135,3 @@ Each construction of IN will be normalized (sorted and deduplicated). TEXT_MATCH
|
||||
- Advanced BinaryRangeExpr merging:
|
||||
- OR with 3+ intervals: Currently limited to 2 intervals. Full interval merging algorithm needed for `(10 < x < 20) OR (15 < x < 25) OR (22 < x < 30)` → `(10 < x < 30)`.
|
||||
- OR with unbounded + bounded: Currently skipped. Could optimize `(x > 10) OR (5 < x < 15)` → `x > 5`.
|
||||
|
||||
|
||||
|
||||
@@ -111,8 +111,14 @@ func (v *visitor) visitUnaryExpr(expr *planpb.UnaryExpr) interface{} {
|
||||
sortTermValues(te)
|
||||
col := te.GetColumnInfo()
|
||||
if v.optimizeEnabled && effectiveDataType(col) == schemapb.DataType_Bool {
|
||||
// Let bool NOT IN flow through to visitTermExpr for bool-specific optimization
|
||||
if !canFoldBoolDomainToConstant(col) && boolValuesCoverDomain(te.GetValues()) {
|
||||
return notExpr(&planpb.Expr{Expr: &planpb.Expr_TermExpr{TermExpr: te}})
|
||||
}
|
||||
// Let other bool NOT IN flow through to visitTermExpr for bool-specific optimization.
|
||||
} else if col != nil && len(te.GetValues()) == 1 {
|
||||
if hasMissingPathNotEqualSemantics(col, te.GetValues()...) {
|
||||
return notExpr(&planpb.Expr{Expr: &planpb.Expr_TermExpr{TermExpr: te}})
|
||||
}
|
||||
// Single-value NOT IN → != (avoids SIMD setup overhead for trivial case)
|
||||
return newUnaryRangeExpr(col, planpb.OpType_NotEqual, te.GetValues()[0])
|
||||
}
|
||||
@@ -132,7 +138,6 @@ func (v *visitor) visitUnaryExpr(expr *planpb.UnaryExpr) interface{} {
|
||||
return newAlwaysFalseExpr()
|
||||
}
|
||||
// NOT (IS NOT NULL) → IS NULL
|
||||
// Handles: nullable bool NOT IN [true, false] → IS NULL
|
||||
if ne := child.GetNullExpr(); ne != nil {
|
||||
if ne.GetOp() == planpb.NullExpr_IsNotNull {
|
||||
return newNullExpr(ne.GetColumnInfo(), planpb.NullExpr_IsNull)
|
||||
@@ -144,6 +149,16 @@ func (v *visitor) visitUnaryExpr(expr *planpb.UnaryExpr) interface{} {
|
||||
// NOT (col == val) → col != val
|
||||
// Handles: bool NOT IN [true] → != true, bool NOT IN [false] → != false
|
||||
if ure := child.GetUnaryRangeExpr(); ure != nil && ure.GetOp() == planpb.OpType_Equal {
|
||||
if hasMissingPathNotEqualSemantics(ure.GetColumnInfo(), ure.GetValue()) {
|
||||
return &planpb.Expr{
|
||||
Expr: &planpb.Expr_UnaryExpr{
|
||||
UnaryExpr: &planpb.UnaryExpr{
|
||||
Op: expr.GetOp(),
|
||||
Child: child,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return newUnaryRangeExpr(ure.GetColumnInfo(), planpb.OpType_NotEqual, ure.GetValue())
|
||||
}
|
||||
}
|
||||
@@ -165,23 +180,15 @@ func (v *visitor) visitTermExpr(expr *planpb.TermExpr) interface{} {
|
||||
}
|
||||
|
||||
// Optimize bool IN expressions:
|
||||
// - in [true, false] → AlwaysTrueExpr (non-nullable) or IS NOT NULL (nullable)
|
||||
// - in [true, false] → AlwaysTrueExpr for non-nullable fields; nullable fields keep TermExpr
|
||||
// - in [true] → == true (uses fast SIMD path instead of slow scalar loop)
|
||||
// - in [false] → == false
|
||||
if v.optimizeEnabled && effectiveDataType(expr.GetColumnInfo()) == schemapb.DataType_Bool {
|
||||
values := expr.GetValues()
|
||||
if allBoolVals(values) {
|
||||
hasFalse, hasTrue := false, false
|
||||
for _, val := range values {
|
||||
if val.GetBoolVal() {
|
||||
hasTrue = true
|
||||
} else {
|
||||
hasFalse = true
|
||||
}
|
||||
}
|
||||
if hasTrue && hasFalse {
|
||||
if expr.GetColumnInfo().GetNullable() {
|
||||
return newNullExpr(expr.GetColumnInfo(), planpb.NullExpr_IsNotNull)
|
||||
if boolValuesCoverDomain(values) {
|
||||
if !canFoldBoolDomainToConstant(expr.GetColumnInfo()) {
|
||||
return &planpb.Expr{Expr: &planpb.Expr_TermExpr{TermExpr: expr}}
|
||||
}
|
||||
return newAlwaysTrueExpr()
|
||||
}
|
||||
@@ -199,6 +206,18 @@ func (v *visitor) visitTermExpr(expr *planpb.TermExpr) interface{} {
|
||||
return &planpb.Expr{Expr: &planpb.Expr_TermExpr{TermExpr: expr}}
|
||||
}
|
||||
|
||||
func boolValuesCoverDomain(values []*planpb.GenericValue) bool {
|
||||
hasFalse, hasTrue := false, false
|
||||
for _, val := range values {
|
||||
if val.GetBoolVal() {
|
||||
hasTrue = true
|
||||
} else {
|
||||
hasFalse = true
|
||||
}
|
||||
}
|
||||
return hasTrue && hasFalse
|
||||
}
|
||||
|
||||
// allBoolVals returns true if all values in the slice are BoolVal type.
|
||||
func allBoolVals(values []*planpb.GenericValue) bool {
|
||||
if len(values) == 0 {
|
||||
|
||||
@@ -206,6 +206,10 @@ func (v *visitor) combineAndRangePredicates(parts []*planpb.Expr) []*planpb.Expr
|
||||
}
|
||||
}
|
||||
|
||||
if isEmpty && hasNullableFieldSemantics(g.col) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, b := range g.lowers {
|
||||
used[b.exprIndex] = true
|
||||
}
|
||||
@@ -604,6 +608,9 @@ func (v *visitor) combineAndBinaryRanges(parts []*planpb.Expr) []*planpb.Expr {
|
||||
}
|
||||
}
|
||||
if isEmpty {
|
||||
if hasNullableFieldSemantics(g.col) {
|
||||
continue
|
||||
}
|
||||
// Empty intersection → constant false
|
||||
for _, iv := range g.intervals {
|
||||
used[iv.exprIndex] = true
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/milvus-io/milvus-proto/go-api/v3/schemapb"
|
||||
parser "github.com/milvus-io/milvus/internal/parser/planparserv2"
|
||||
"github.com/milvus-io/milvus/internal/parser/planparserv2/rewriter"
|
||||
"github.com/milvus-io/milvus/pkg/v3/proto/planpb"
|
||||
"github.com/milvus-io/milvus/pkg/v3/util/typeutil"
|
||||
)
|
||||
@@ -16,6 +17,7 @@ func buildSchemaHelperWithJSON(t *testing.T) *typeutil.SchemaHelper {
|
||||
{FieldID: 101, Name: "Int64Field", DataType: schemapb.DataType_Int64},
|
||||
{FieldID: 102, Name: "JSONField", DataType: schemapb.DataType_JSON},
|
||||
{FieldID: 103, Name: "$meta", DataType: schemapb.DataType_JSON, IsDynamic: true},
|
||||
{FieldID: 104, Name: "NullableJSONField", DataType: schemapb.DataType_JSON, Nullable: true},
|
||||
}
|
||||
schema := &schemapb.CollectionSchema{
|
||||
Name: "rewrite_json_test",
|
||||
@@ -28,6 +30,86 @@ func buildSchemaHelperWithJSON(t *testing.T) *typeutil.SchemaHelper {
|
||||
return helper
|
||||
}
|
||||
|
||||
func TestRewrite_JSON_NestedPath_NullableColumnInfo(t *testing.T) {
|
||||
helper := buildSchemaHelperWithJSON(t)
|
||||
|
||||
expr, err := parser.ParseExpr(helper, `NullableJSONField["age"] in [1, 2]`, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expr)
|
||||
term := expr.GetTermExpr()
|
||||
require.NotNil(t, term)
|
||||
require.True(t, term.GetColumnInfo().GetNullable())
|
||||
require.Equal(t, []string{"age"}, term.GetColumnInfo().GetNestedPath())
|
||||
}
|
||||
|
||||
func TestRewrite_JSON_NestedPath_Nullable_TautologyKeepsPredicate(t *testing.T) {
|
||||
helper := buildSchemaHelperWithJSON(t)
|
||||
|
||||
expr, err := parser.ParseExpr(helper, `NullableJSONField["age"] in [1, 2] or NullableJSONField["age"] != 1`, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expr)
|
||||
require.False(t, rewriter.IsAlwaysTrueExpr(expr))
|
||||
require.NotNil(t, expr.GetBinaryExpr())
|
||||
}
|
||||
|
||||
func TestRewrite_JSON_NestedPath_Nullable_ContradictionsKeepPredicate(t *testing.T) {
|
||||
helper := buildSchemaHelperWithJSON(t)
|
||||
|
||||
for _, exprStr := range []string{
|
||||
`NullableJSONField["age"] in [1] and NullableJSONField["age"] == 2`,
|
||||
`NullableJSONField["age"] > 100 and NullableJSONField["age"] < 50`,
|
||||
`not (NullableJSONField["age"] in [1] and NullableJSONField["age"] == 2)`,
|
||||
`not (NullableJSONField["age"] > 100 and NullableJSONField["age"] < 50)`,
|
||||
} {
|
||||
expr, err := parser.ParseExpr(helper, exprStr, nil)
|
||||
require.NoError(t, err, exprStr)
|
||||
require.NotNil(t, expr, exprStr)
|
||||
require.False(t, rewriter.IsAlwaysFalseExpr(expr), "nullable JSON path must not fold to valid false: %s", exprStr)
|
||||
require.False(t, rewriter.IsAlwaysTrueExpr(expr), "nullable JSON path under NOT must not fold to valid true: %s", exprStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewrite_JSON_NestedPath_ScalarNotEqualRewriteAllowed(t *testing.T) {
|
||||
helper := buildSchemaHelperWithJSON(t)
|
||||
|
||||
for _, exprStr := range []string{
|
||||
`not (JSONField["age"] == 1)`,
|
||||
`JSONField["age"] not in [1]`,
|
||||
} {
|
||||
expr, err := parser.ParseExpr(helper, exprStr, nil)
|
||||
require.NoError(t, err, exprStr)
|
||||
require.NotNil(t, expr, exprStr)
|
||||
ure := expr.GetUnaryRangeExpr()
|
||||
require.NotNil(t, ure, "scalar JSON missing-path != is compatible with NOT(==): %s", exprStr)
|
||||
require.Equal(t, planpb.OpType_NotEqual, ure.GetOp(), exprStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewrite_JSON_NestedPath_ArrayNotEqualRewriteBlocked(t *testing.T) {
|
||||
helper := buildSchemaHelperWithJSON(t)
|
||||
|
||||
for _, exprStr := range []string{
|
||||
`not (JSONField["arr"] == [1, 2])`,
|
||||
`JSONField["arr"] not in [[1, 2]]`,
|
||||
} {
|
||||
expr, err := parser.ParseExpr(helper, exprStr, nil)
|
||||
require.NoError(t, err, exprStr)
|
||||
require.NotNil(t, expr, exprStr)
|
||||
unary := expr.GetUnaryExpr()
|
||||
require.NotNil(t, unary, "array-valued JSON missing-path NOT must remain explicit: %s", exprStr)
|
||||
require.Equal(t, planpb.UnaryExpr_Not, unary.GetOp(), exprStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewrite_JSON_NestedPath_ArrayNotEqualsKeepPredicates(t *testing.T) {
|
||||
helper := buildSchemaHelperWithJSON(t)
|
||||
|
||||
expr, err := parser.ParseExpr(helper, `JSONField["arr"] != [1, 2] and JSONField["arr"] != [3, 4]`, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expr)
|
||||
require.NotNil(t, expr.GetBinaryExpr(), "array-valued JSON != chain must not become NOT(IN)")
|
||||
}
|
||||
|
||||
// Test JSON field with int comparison - AND tightening
|
||||
func TestRewrite_JSON_Int_AND_Strengthen(t *testing.T) {
|
||||
helper := buildSchemaHelperWithJSON(t)
|
||||
|
||||
@@ -244,6 +244,8 @@ func buildSchemaHelperWithArraysT(t *testing.T) *typeutil.SchemaHelper {
|
||||
{FieldID: 201, Name: "ArrayInt", DataType: schemapb.DataType_Array, ElementType: schemapb.DataType_Int64},
|
||||
{FieldID: 202, Name: "ArrayFloat", DataType: schemapb.DataType_Array, ElementType: schemapb.DataType_Double},
|
||||
{FieldID: 203, Name: "ArrayVarchar", DataType: schemapb.DataType_Array, ElementType: schemapb.DataType_VarChar},
|
||||
{FieldID: 204, Name: "ArrayBool", DataType: schemapb.DataType_Array, ElementType: schemapb.DataType_Bool},
|
||||
{FieldID: 205, Name: "NullableArrayInt", DataType: schemapb.DataType_Array, ElementType: schemapb.DataType_Int64, Nullable: true},
|
||||
}
|
||||
schema := &schemapb.CollectionSchema{
|
||||
Name: "rewrite_array_test",
|
||||
@@ -428,6 +430,31 @@ func TestRewrite_Range_AND_InvalidRange_String_LowerGreaterThanUpper(t *testing.
|
||||
require.True(t, rewriter.IsAlwaysFalseExpr(expr))
|
||||
}
|
||||
|
||||
func TestRewrite_Range_AND_InvalidRange_Nullable_KeepsPredicate(t *testing.T) {
|
||||
helper := buildSchemaHelperForRewriteNullableT(t)
|
||||
|
||||
expr, err := parser.ParseExpr(helper, `NullableInt64Field > 100 and NullableInt64Field < 50`, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expr)
|
||||
require.False(t, rewriter.IsAlwaysFalseExpr(expr), "nullable impossible range must not fold to valid false")
|
||||
require.NotNil(t, expr.GetBinaryExpr())
|
||||
}
|
||||
|
||||
func TestRewrite_Range_AND_InvalidRange_Nullable_UnderNotDoesNotBecomeAlwaysTrue(t *testing.T) {
|
||||
helper := buildSchemaHelperForRewriteNullableT(t)
|
||||
|
||||
for _, exprStr := range []string{
|
||||
`not (NullableInt64Field > 100 and NullableInt64Field < 50)`,
|
||||
`not ((NullableInt64Field > 10 and NullableInt64Field < 20) and (NullableInt64Field > 30 and NullableInt64Field < 40))`,
|
||||
} {
|
||||
expr, err := parser.ParseExpr(helper, exprStr, nil)
|
||||
require.NoError(t, err, exprStr)
|
||||
require.NotNil(t, expr, exprStr)
|
||||
require.False(t, rewriter.IsAlwaysTrueExpr(expr), "nullable impossible range under NOT must preserve NULL semantics: %s", exprStr)
|
||||
require.NotNil(t, expr.GetUnaryExpr(), "nullable impossible range under NOT should remain negated: %s", exprStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Test AlwaysFalse propagation through nested AND expressions
|
||||
func TestRewrite_AlwaysFalse_Propagation_DeepNesting(t *testing.T) {
|
||||
helper := buildSchemaHelperForRewriteT(t)
|
||||
|
||||
@@ -93,6 +93,12 @@ func (v *visitor) combineAndNotEqualsToNotIn(parts []*planpb.Expr) []*planpb.Exp
|
||||
out = append(out, others...)
|
||||
for _, g := range groups {
|
||||
if len(g.values) >= 2 {
|
||||
if hasMissingPathNotEqualSemantics(g.col, g.values...) {
|
||||
for _, i := range g.origIndices {
|
||||
out = append(out, indexToExpr[i])
|
||||
}
|
||||
continue
|
||||
}
|
||||
g.values = sortGenericValues(g.values)
|
||||
in := newTermExpr(g.col, g.values)
|
||||
out = append(out, notExpr(in))
|
||||
@@ -179,6 +185,9 @@ func (v *visitor) combineAndInWithEqual(parts []*planpb.Expr) []*planpb.Expr {
|
||||
}
|
||||
// If multiple different equals present, AND implies contradiction unless identical.
|
||||
if len(eqUnique) > 1 {
|
||||
if hasNullableFieldSemantics(g.col) {
|
||||
continue
|
||||
}
|
||||
for _, ti := range g.termIdxs {
|
||||
used[ti] = true
|
||||
}
|
||||
@@ -198,6 +207,9 @@ func (v *visitor) combineAndInWithEqual(parts []*planpb.Expr) []*planpb.Expr {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inSet && hasNullableFieldSemantics(g.col) {
|
||||
continue
|
||||
}
|
||||
for _, ti := range g.termIdxs {
|
||||
used[ti] = true
|
||||
}
|
||||
@@ -368,6 +380,9 @@ func (v *visitor) combineAndInWithRange(parts []*planpb.Expr) []*planpb.Expr {
|
||||
continue
|
||||
}
|
||||
filtered := filterValuesByRange(effectiveDataType(g.col), termVals, g.lower, g.lowerInc, g.upper, g.upperInc)
|
||||
if len(filtered) == 0 && hasNullableFieldSemantics(g.col) {
|
||||
continue
|
||||
}
|
||||
used[g.termIdx] = true
|
||||
for _, ri := range g.rangeIdxs {
|
||||
used[ri] = true
|
||||
@@ -493,6 +508,9 @@ func (v *visitor) combineAndInWithIn(parts []*planpb.Expr) []*planpb.Expr {
|
||||
inter = append(inter, v)
|
||||
}
|
||||
}
|
||||
if len(inter) == 0 && hasNullableFieldSemantics(g.col) {
|
||||
continue
|
||||
}
|
||||
for _, i := range g.idxs {
|
||||
used[i] = true
|
||||
}
|
||||
@@ -569,6 +587,9 @@ func (v *visitor) combineAndInWithNotEqual(parts []*planpb.Expr) []*planpb.Expr
|
||||
filtered = append(filtered, tv)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 && hasNullableFieldSemantics(g.col) {
|
||||
continue
|
||||
}
|
||||
used[g.termIdx] = true
|
||||
for _, ni := range g.neqIdxs {
|
||||
used[ni] = true
|
||||
@@ -647,6 +668,9 @@ func (v *visitor) combineOrInWithNotEqual(parts []*planpb.Expr) []*planpb.Expr {
|
||||
}
|
||||
}
|
||||
if containsAny {
|
||||
if !canFoldInNotEqualTautologyToTrue(g.col) {
|
||||
continue
|
||||
}
|
||||
used[g.termIdx] = true
|
||||
for _, ni := range g.neqIdxs {
|
||||
used[ni] = true
|
||||
|
||||
@@ -44,6 +44,8 @@ func buildSchemaHelperForRewriteNullableT(t *testing.T) *typeutil.SchemaHelper {
|
||||
fields := []*schemapb.FieldSchema{
|
||||
{FieldID: 101, Name: "Int64Field", DataType: schemapb.DataType_Int64},
|
||||
{FieldID: 106, Name: "NullableBoolField", DataType: schemapb.DataType_Bool, Nullable: true},
|
||||
{FieldID: 107, Name: "NullableInt64Field", DataType: schemapb.DataType_Int64, Nullable: true},
|
||||
{FieldID: 108, Name: "NullableVarCharField", DataType: schemapb.DataType_VarChar, Nullable: true},
|
||||
}
|
||||
schema := &schemapb.CollectionSchema{
|
||||
Name: "rewrite_nullable_test",
|
||||
@@ -251,16 +253,14 @@ func TestRewrite_Bool_In_DedupedSingleTrue_ToEqual(t *testing.T) {
|
||||
require.Equal(t, true, ure.GetValue().GetBoolVal())
|
||||
}
|
||||
|
||||
func TestRewrite_Bool_In_TrueFalse_Nullable_ToIsNotNull(t *testing.T) {
|
||||
// For nullable bool, in [true, false] should become IS NOT NULL (not AlwaysTrueExpr)
|
||||
// because null values should not match.
|
||||
func TestRewrite_Bool_In_TrueFalse_Nullable_KeepsTerm(t *testing.T) {
|
||||
// Keep the original comparison for nullable bool. It is equivalent to IS NOT NULL
|
||||
// as a final filter, but not under NOT because NULL must stay unknown.
|
||||
helper := buildSchemaHelperForRewriteNullableT(t)
|
||||
expr, err := parser.ParseExpr(helper, `NullableBoolField in [true,false]`, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expr)
|
||||
nullExpr := expr.GetNullExpr()
|
||||
require.NotNil(t, nullExpr, "nullable bool IN [true, false] should be rewritten to IS NOT NULL")
|
||||
require.Equal(t, planpb.NullExpr_IsNotNull, nullExpr.GetOp())
|
||||
require.NotNil(t, expr.GetTermExpr(), "nullable bool IN [true, false] should remain a TermExpr")
|
||||
}
|
||||
|
||||
func TestRewrite_Bool_In_SingleTrue_Nullable_ToEqual(t *testing.T) {
|
||||
@@ -285,15 +285,16 @@ func TestRewrite_Bool_NotIn_TrueFalse_ToAlwaysFalse(t *testing.T) {
|
||||
"bool NOT IN [true, false] should be rewritten to AlwaysFalseExpr")
|
||||
}
|
||||
|
||||
func TestRewrite_Bool_NotIn_TrueFalse_Nullable_ToIsNull(t *testing.T) {
|
||||
func TestRewrite_Bool_NotIn_TrueFalse_Nullable_KeepsNotTerm(t *testing.T) {
|
||||
helper := buildSchemaHelperForRewriteNullableT(t)
|
||||
// not in [true, false] on nullable → only NULL matches → IS NULL
|
||||
// Keep NOT(IN) so NULL remains unknown rather than becoming a matching IS NULL.
|
||||
expr, err := parser.ParseExpr(helper, `NullableBoolField not in [true,false]`, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expr)
|
||||
nullExpr := expr.GetNullExpr()
|
||||
require.NotNil(t, nullExpr, "nullable bool NOT IN [true, false] should be rewritten to IS NULL")
|
||||
require.Equal(t, planpb.NullExpr_IsNull, nullExpr.GetOp())
|
||||
unary := expr.GetUnaryExpr()
|
||||
require.NotNil(t, unary, "nullable bool NOT IN [true, false] should remain NOT(TermExpr)")
|
||||
require.Equal(t, planpb.UnaryExpr_Not, unary.GetOp())
|
||||
require.NotNil(t, unary.GetChild().GetTermExpr())
|
||||
}
|
||||
|
||||
func TestRewrite_Bool_NotIn_SingleTrue_ToNotEqual(t *testing.T) {
|
||||
@@ -320,6 +321,53 @@ func TestRewrite_Bool_NotIn_SingleFalse_ToNotEqual(t *testing.T) {
|
||||
require.Equal(t, false, ure.GetValue().GetBoolVal())
|
||||
}
|
||||
|
||||
func TestRewrite_Bool_ArrayIndex_In_TrueFalse_KeepsTerm(t *testing.T) {
|
||||
helper := buildSchemaHelperWithArraysT(t)
|
||||
|
||||
expr, err := parser.ParseExpr(helper, `ArrayBool[0] in [true,false]`, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expr)
|
||||
require.NotNil(t, expr.GetTermExpr(), "indexed array bool IN may be false when the index is absent")
|
||||
}
|
||||
|
||||
func TestRewrite_Bool_ArrayIndex_NotIn_TrueFalse_KeepsNotTerm(t *testing.T) {
|
||||
helper := buildSchemaHelperWithArraysT(t)
|
||||
|
||||
expr, err := parser.ParseExpr(helper, `ArrayBool[0] not in [true,false]`, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expr)
|
||||
unary := expr.GetUnaryExpr()
|
||||
require.NotNil(t, unary)
|
||||
require.Equal(t, planpb.UnaryExpr_Not, unary.GetOp())
|
||||
require.NotNil(t, unary.GetChild().GetTermExpr(), "indexed array bool NOT IN must not become a valid constant")
|
||||
}
|
||||
|
||||
func TestRewrite_ArrayIndex_NotInSingle_KeepsNotTerm(t *testing.T) {
|
||||
helper := buildSchemaHelperWithArraysT(t)
|
||||
|
||||
expr, err := parser.ParseExpr(helper, `ArrayInt[0] not in [1]`, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expr)
|
||||
unary := expr.GetUnaryExpr()
|
||||
require.NotNil(t, unary)
|
||||
require.Equal(t, planpb.UnaryExpr_Not, unary.GetOp())
|
||||
require.NotNil(t, unary.GetChild().GetTermExpr(), "indexed array NOT(IN) is not equivalent to indexed !=")
|
||||
}
|
||||
|
||||
func TestRewrite_ArrayIndex_NotEqualComplement_KeepsNotEqual(t *testing.T) {
|
||||
helper := buildSchemaHelperWithArraysT(t)
|
||||
|
||||
expr, err := parser.ParseExpr(helper, `not (ArrayInt[0] == 1)`, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expr)
|
||||
unary := expr.GetUnaryExpr()
|
||||
require.NotNil(t, unary)
|
||||
require.Equal(t, planpb.UnaryExpr_Not, unary.GetOp())
|
||||
ure := unary.GetChild().GetUnaryRangeExpr()
|
||||
require.NotNil(t, ure)
|
||||
require.Equal(t, planpb.OpType_Equal, ure.GetOp())
|
||||
}
|
||||
|
||||
func TestRewrite_Flatten_Then_OR_ToIN(t *testing.T) {
|
||||
helper := buildSchemaHelperForRewriteT(t)
|
||||
// nested OR-equals should flatten and merge to IN
|
||||
@@ -446,6 +494,86 @@ func TestRewrite_Or_In_Or_NotEqual_VarChar_Tautology(t *testing.T) {
|
||||
"OR(IN, !=) tautology with VarChar should be rewritten to AlwaysTrueExpr")
|
||||
}
|
||||
|
||||
func TestRewrite_Or_In_Or_NotEqual_Nullable_KeepsOriginalPredicate(t *testing.T) {
|
||||
helper := buildSchemaHelperForRewriteNullableT(t)
|
||||
|
||||
for _, exprStr := range []string{
|
||||
`NullableInt64Field in [1, 2] or NullableInt64Field != 1`,
|
||||
`NullableVarCharField in ["", "a", "b"] or NullableVarCharField != ""`,
|
||||
} {
|
||||
expr, err := parser.ParseExpr(helper, exprStr, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expr)
|
||||
require.NotNil(t, expr.GetBinaryExpr(), "nullable OR(IN, !=) should keep OR predicate shape: %s", exprStr)
|
||||
require.NotNil(t, findTermExpr(expr), "nullable OR(IN, !=) should keep IN term: %s", exprStr)
|
||||
require.NotNil(t, findUnaryRangeExpr(expr, planpb.OpType_NotEqual), "nullable OR(IN, !=) should keep != predicate: %s", exprStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewrite_Or_In_Or_NotEqual_ArrayIndex_KeepsOriginalPredicate(t *testing.T) {
|
||||
helper := buildSchemaHelperWithArraysT(t)
|
||||
|
||||
expr, err := parser.ParseExpr(helper, `ArrayInt[0] in [1, 2] or ArrayInt[0] != 1`, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expr)
|
||||
require.False(t, rewriter.IsAlwaysTrueExpr(expr), "indexed array OR(IN, !=) is not a tautology when the index is absent")
|
||||
require.NotNil(t, expr.GetBinaryExpr())
|
||||
}
|
||||
|
||||
func TestRewrite_And_NotEquals_ArrayIndex_KeepsPredicates(t *testing.T) {
|
||||
helper := buildSchemaHelperWithArraysT(t)
|
||||
|
||||
expr, err := parser.ParseExpr(helper, `ArrayInt[0] != 1 and ArrayInt[0] != 2`, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expr)
|
||||
require.NotNil(t, expr.GetBinaryExpr(), "indexed array != chain must not become NOT(IN)")
|
||||
require.NotNil(t, findUnaryRangeExpr(expr, planpb.OpType_NotEqual))
|
||||
}
|
||||
|
||||
func TestRewrite_NullableArrayIndex_ContradictionsKeepPredicate(t *testing.T) {
|
||||
helper := buildSchemaHelperWithArraysT(t)
|
||||
|
||||
for _, exprStr := range []string{
|
||||
`NullableArrayInt[0] in [1] and NullableArrayInt[0] == 2`,
|
||||
`not (NullableArrayInt[0] in [1] and NullableArrayInt[0] == 2)`,
|
||||
} {
|
||||
expr, err := parser.ParseExpr(helper, exprStr, nil)
|
||||
require.NoError(t, err, exprStr)
|
||||
require.NotNil(t, expr, exprStr)
|
||||
require.False(t, rewriter.IsAlwaysFalseExpr(expr), "nullable indexed array must not fold to valid false: %s", exprStr)
|
||||
require.False(t, rewriter.IsAlwaysTrueExpr(expr), "nullable indexed array under NOT must not fold to valid true: %s", exprStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewrite_Or_In_Or_NotEqual_Nullable_NotDoesNotBecomeIsNull(t *testing.T) {
|
||||
helper := buildSchemaHelperForRewriteNullableT(t)
|
||||
|
||||
expr, err := parser.ParseExpr(helper, `not (NullableInt64Field in [1, 2] or NullableInt64Field != 1)`, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expr)
|
||||
require.Nil(t, expr.GetNullExpr(), "negated nullable tautology must not become IS NULL")
|
||||
unary := expr.GetUnaryExpr()
|
||||
require.NotNil(t, unary)
|
||||
require.NotNil(t, unary.GetChild().GetBinaryExpr())
|
||||
}
|
||||
|
||||
func TestRewrite_NullableContradictions_UnderNot_DoNotBecomeAlwaysTrue(t *testing.T) {
|
||||
helper := buildSchemaHelperForRewriteNullableT(t)
|
||||
|
||||
for _, exprStr := range []string{
|
||||
`not (NullableInt64Field in [1] and NullableInt64Field == 2)`,
|
||||
`not (NullableInt64Field in [1] and NullableInt64Field in [2])`,
|
||||
`not (NullableInt64Field in [1] and NullableInt64Field != 1)`,
|
||||
`not (NullableInt64Field in [1] and NullableInt64Field > 2)`,
|
||||
} {
|
||||
expr, err := parser.ParseExpr(helper, exprStr, nil)
|
||||
require.NoError(t, err, exprStr)
|
||||
require.NotNil(t, expr, exprStr)
|
||||
require.False(t, rewriter.IsAlwaysTrueExpr(expr), "nullable contradiction under NOT must preserve NULL semantics: %s", exprStr)
|
||||
require.NotNil(t, expr.GetUnaryExpr(), "nullable contradiction under NOT should remain negated: %s", exprStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewrite_Or_In_Or_NotEqual_VNotInSet_ToNotEqual(t *testing.T) {
|
||||
helper := buildSchemaHelperForRewriteT(t)
|
||||
expr, err := parser.ParseExpr(helper, `Int64Field in [1,2,3,4,5,6,7,8,9,10] or Int64Field != 20`, nil)
|
||||
@@ -542,6 +670,25 @@ func findTermExpr(expr *planpb.Expr) *planpb.TermExpr {
|
||||
return nil
|
||||
}
|
||||
|
||||
func findUnaryRangeExpr(expr *planpb.Expr, op planpb.OpType) *planpb.UnaryRangeExpr {
|
||||
if expr == nil {
|
||||
return nil
|
||||
}
|
||||
if ure := expr.GetUnaryRangeExpr(); ure != nil && ure.GetOp() == op {
|
||||
return ure
|
||||
}
|
||||
if be := expr.GetBinaryExpr(); be != nil {
|
||||
if found := findUnaryRangeExpr(be.GetLeft(), op); found != nil {
|
||||
return found
|
||||
}
|
||||
return findUnaryRangeExpr(be.GetRight(), op)
|
||||
}
|
||||
if ue := expr.GetUnaryExpr(); ue != nil {
|
||||
return findUnaryRangeExpr(ue.GetChild(), op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestTimestamptz_NotEqual_InAndContext verifies that a single != on a
|
||||
// Timestamptz field inside an AND expression stays as UnaryRangeExpr.
|
||||
// combineAndNotEqualsToNotIn requires 2+ values to merge into NOT(IN),
|
||||
|
||||
@@ -15,13 +15,15 @@ import (
|
||||
|
||||
func columnKey(c *planpb.ColumnInfo) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%d|%d|%d|%t|%t|%t|",
|
||||
fmt.Fprintf(&b, "%d|%d|%d|%t|%t|%t|%t|%t|",
|
||||
c.GetFieldId(),
|
||||
int32(c.GetDataType()),
|
||||
int32(c.GetElementType()),
|
||||
c.GetIsPrimaryKey(),
|
||||
c.GetIsAutoID(),
|
||||
c.GetIsPartitionKey())
|
||||
c.GetIsPartitionKey(),
|
||||
c.GetNullable(),
|
||||
c.GetIsElementLevel())
|
||||
for _, p := range c.GetNestedPath() {
|
||||
b.WriteString(p)
|
||||
b.WriteByte('|')
|
||||
@@ -203,6 +205,40 @@ func newAlwaysTrueExpr() *planpb.Expr {
|
||||
}
|
||||
}
|
||||
|
||||
func hasNullableFieldSemantics(col *planpb.ColumnInfo) bool {
|
||||
return col != nil && col.GetNullable()
|
||||
}
|
||||
|
||||
func hasMissingPathSemantics(col *planpb.ColumnInfo) bool {
|
||||
return col != nil && len(col.GetNestedPath()) > 0
|
||||
}
|
||||
|
||||
func canFoldBoolDomainToConstant(col *planpb.ColumnInfo) bool {
|
||||
return !hasNullableFieldSemantics(col) && !hasMissingPathSemantics(col)
|
||||
}
|
||||
|
||||
func canFoldInNotEqualTautologyToTrue(col *planpb.ColumnInfo) bool {
|
||||
return !hasNullableFieldSemantics(col) && !hasMissingPathSemantics(col)
|
||||
}
|
||||
|
||||
func hasMissingPathNotEqualSemantics(col *planpb.ColumnInfo, values ...*planpb.GenericValue) bool {
|
||||
if !hasMissingPathSemantics(col) {
|
||||
return false
|
||||
}
|
||||
if col.GetDataType() != schemapb.DataType_JSON {
|
||||
return true
|
||||
}
|
||||
for _, value := range values {
|
||||
if value == nil || value.GetVal() == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := value.GetVal().(*planpb.GenericValue_ArrayVal); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newAlwaysFalseExpr() *planpb.Expr {
|
||||
return &planpb.Expr{
|
||||
Expr: &planpb.Expr_UnaryExpr{
|
||||
|
||||
Reference in New Issue
Block a user