fix: preserve nullable expr rewrite semantics (#50555)

issue: #49891

---------

Signed-off-by: Buqian Zheng <zhengbuqian@gmail.com>
This commit is contained in:
Buqian Zheng
2026-06-17 17:58:23 +08:00
committed by GitHub
parent 0ea705382c
commit fd26a644e6
9 changed files with 389 additions and 31 deletions
+19 -2
View File
@@ -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`.
+33 -14
View File
@@ -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),
+38 -2
View File
@@ -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{