diff --git a/enginetest/queries/queries.go b/enginetest/queries/queries.go index b336540d9d..1c3cfbcbc0 100644 --- a/enginetest/queries/queries.go +++ b/enginetest/queries/queries.go @@ -2754,6 +2754,36 @@ SELECT * FROM cte WHERE d = 2;`, Query: "SELECT 100 NOT IN (SELECT i2 FROM niltable)", Expected: []sql.Row{{nil}}, }, + // Tuple IN (SELECT ...) three-valued membership (NULL stragglers after #3651) + { + Query: "SELECT (1, 5) IN (SELECT 1, NULL FROM dual)", + Expected: []sql.Row{{nil}}, + }, + { + Query: "SELECT (1, 5) IN (SELECT * FROM (SELECT 1, NULL FROM dual UNION ALL SELECT 2, 3 FROM dual) t)", + Expected: []sql.Row{{nil}}, + }, + { + Query: "SELECT (1, NULL) IN (SELECT 1, 2 FROM dual)", + Expected: []sql.Row{{nil}}, + }, + { + Query: "SELECT (NULL, NULL) IN (SELECT 1, 2 FROM dual)", + Expected: []sql.Row{{nil}}, + }, + { + Query: "SELECT (1, 5) NOT IN (SELECT 1, NULL FROM dual)", + Expected: []sql.Row{{nil}}, + }, + { + // Definite FALSE: non-null left component mismatches despite right NULL + Query: "SELECT (1, NULL) IN (SELECT 2, NULL FROM dual)", + Expected: []sql.Row{{false}}, + }, + { + Query: "SELECT (1, 5) IN (SELECT 1, 5 FROM dual)", + Expected: []sql.Row{{true}}, + }, { Query: "SELECT 1 IN (2,3,4,null)", Expected: []sql.Row{{nil}}, @@ -9721,14 +9751,6 @@ FROM mytable;`, {"DECIMAL"}, }, }, - { - Query: "SELECT (1, 5) IN (SELECT 1, NULL FROM dual)", - Expected: []sql.Row{{nil}}, - }, - { - Query: "SELECT (1, 5) IN (SELECT * FROM (SELECT 1, NULL FROM dual UNION ALL SELECT 2, 3 FROM dual) t)", - Expected: []sql.Row{{nil}}, - }, } var VersionedQueries = []QueryTest{ diff --git a/sql/plan/insubquery.go b/sql/plan/insubquery.go index 57073b25ff..c87c5dffc3 100644 --- a/sql/plan/insubquery.go +++ b/sql/plan/insubquery.go @@ -95,6 +95,12 @@ func (in *InSubquery) Eval(ctx *sql.Context, row sql.Row) (interface{}, error) { // convert left to right's type nLeft, _, err := rTyp.Convert(ctx, left) if err != nil { + // SELECT 1, NULL yields tuple(..., null). Converting a non-NULL left + // component into a null-typed column fails, but membership is still + // three-valued — scan rather than treating it as definite FALSE. + if tup, ok := rTyp.(types.TupleType); ok { + return evalTupleInSubquery(ctx, right, row, left, tup, rTyp) + } return false, nil } @@ -105,6 +111,12 @@ func (in *InSubquery) Eval(ctx *sql.Context, row sql.Row) (interface{}, error) { val, notFoundErr := values.Get(key) if notFoundErr != nil { + // Hash miss: scalars only need a whole-NULL probe. Tuples need a + // three-valued scan — right rows with NULL components hash away from + // non-NULL lefts (and lefts with NULL components never hash-hit). + if tup, ok := rTyp.(types.TupleType); ok { + return evalTupleInSubquery(ctx, right, row, nLeft, tup, rTyp) + } if _, nilValNotFoundErr := values.Get(nilKey); nilValNotFoundErr == nil { return nil, nil } @@ -117,7 +129,7 @@ func (in *InSubquery) Eval(ctx *sql.Context, row sql.Row) (interface{}, error) { } if tup, ok := rTyp.(types.TupleType); ok { - lVals, lok := left.([]interface{}) + lVals, lok := nLeft.([]interface{}) rVals, rok := val.([]interface{}) if lok && rok && len(lVals) == len(rVals) && len(lVals) == len(tup) { cmp, hasNil, cErr := expression.NullUnsafeCompareTuples(ctx, lVals, rVals, tup) @@ -191,6 +203,59 @@ func (in *InSubquery) Dispose(ctx *sql.Context) { } } +// evalTupleInSubquery performs three-valued membership for row constructors +// against a subquery result set. Definite TRUE wins; otherwise any comparison +// that is unknown (hasNil && cmp == 0) yields NULL; else FALSE. +func evalTupleInSubquery(ctx *sql.Context, sq *Subquery, row sql.Row, left interface{}, tup types.TupleType, rTyp sql.Type) (interface{}, error) { + lVals, lok := left.([]interface{}) + if !lok { + return false, nil + } + + allVals, err := sq.EvalMultiple(ctx, row) + if err != nil { + return nil, err + } + + var hasNullCmp bool + for _, rv := range allVals { + if rv == nil { + hasNullCmp = true + continue + } + rVal, _, cErr := rTyp.Convert(ctx, rv) + if cErr != nil { + // Fall back to the raw row when type conversion fails (null-typed cols). + rVal = rv + } + if rVal == nil { + hasNullCmp = true + continue + } + rVals, rok := rVal.([]interface{}) + if !rok || len(rVals) != len(lVals) || len(rVals) != len(tup) { + continue + } + cmp, hasNil, cmpErr := expression.NullUnsafeCompareTuples(ctx, lVals, rVals, tup) + if cmpErr != nil { + return nil, cmpErr + } + if hasNil { + if cmp == 0 { + hasNullCmp = true + } + continue + } + if cmp == 0 { + return true, nil + } + } + if hasNullCmp { + return nil, nil + } + return false, nil +} + // NewNotInSubquery creates a new NotInSubquery expression. func NewNotInSubquery(ctx *sql.Context, left sql.Expression, right sql.Expression) sql.Expression { return expression.NewNot(NewInSubquery(ctx, left, right))