1+ // Copyright 2021-present StarRocks, Inc. All rights reserved.
2+ //
3+ // Licensed under the Apache License, Version 2.0 (the "License");
4+ // you may not use this file except in compliance with the License.
5+ // You may obtain a copy of the License at
6+ //
7+ // https://www.apache.org/licenses/LICENSE-2.0
8+ //
9+ // Unless required by applicable law or agreed to in writing, software
10+ // distributed under the License is distributed on an "AS IS" BASIS,
11+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+ // See the License for the specific language governing permissions and
13+ // limitations under the License.
14+
15+ package com .starrocks .sql .plan ;
16+
17+ import com .starrocks .sql .analyzer .Analyzer ;
18+ import com .starrocks .sql .ast .QueryStatement ;
19+ import com .starrocks .sql .ast .StatementBase ;
20+ import com .starrocks .sql .optimizer .OptExpression ;
21+ import com .starrocks .sql .optimizer .base .ColumnRefFactory ;
22+ import com .starrocks .sql .optimizer .operator .OperatorType ;
23+ import com .starrocks .sql .optimizer .operator .logical .LogicalJoinOperator ;
24+ import com .starrocks .sql .optimizer .operator .logical .LogicalProjectOperator ;
25+ import com .starrocks .sql .optimizer .operator .scalar .ColumnRefOperator ;
26+ import com .starrocks .sql .optimizer .operator .scalar .LambdaFunctionOperator ;
27+ import com .starrocks .sql .optimizer .operator .scalar .ScalarOperator ;
28+ import com .starrocks .sql .optimizer .transformer .LogicalPlan ;
29+ import com .starrocks .sql .optimizer .transformer .RelationTransformer ;
30+ import com .starrocks .sql .parser .SqlParser ;
31+ import org .junit .jupiter .api .Assertions ;
32+ import org .junit .jupiter .api .BeforeAll ;
33+ import org .junit .jupiter .api .Test ;
34+
35+ import java .util .ArrayList ;
36+ import java .util .HashMap ;
37+ import java .util .List ;
38+ import java .util .Map ;
39+ import java .util .Set ;
40+ import java .util .TreeSet ;
41+
42+ // Regression test for historical bug fixed by #73273: when the same analyzed AST is
43+ // transformed twice with different ColumnRefFactory instances, lambda-argument ColumnRefOperator
44+ // ids must not leak across factories and collide with scalar column ids.
45+ //
46+ // The original bug cached the lambda argument ColumnRefOperator on the AST node
47+ // (LambdaArgument.transformedOp). A second transform with a fresh factory could then reuse that
48+ // stale id and collide with a newly created scalar column id, corrupting types (e.g. VARCHAR
49+ // transaction_uuid/client_key becoming the array element struct<...>) and producing invalid
50+ // predicates (e.g. struct = varchar join conjunct).
51+ public class LambdaArgIdCollisionTest extends PlanTestNoneDBBase {
52+ @ BeforeAll
53+ public static void beforeClass () throws Exception {
54+ PlanTestNoneDBBase .beforeClass ();
55+ starRocksAssert .withDatabase ("repro_lambda" ).useDatabase ("repro_lambda" );
56+ starRocksAssert .withTable ("CREATE TABLE `conn_event` (\n " +
57+ " `transaction_uuid` varchar(200) NULL,\n " +
58+ " `client_key` varchar(200) NULL,\n " +
59+ " `request_attributes` ARRAY<STRUCT<name varchar(200), value varchar(200)>> NULL,\n " +
60+ " `errors` ARRAY<STRUCT<code varchar(200), description varchar(200)>> NULL\n " +
61+ ") ENGINE=OLAP\n " +
62+ "DUPLICATE KEY(`transaction_uuid`)\n " +
63+ "DISTRIBUTED BY HASH(`transaction_uuid`) BUCKETS 3\n " +
64+ "PROPERTIES (\" replication_num\" = \" 1\" );" );
65+ }
66+
67+ @ Test
68+ public void testLambdaArgDoesNotCorruptScalarColumnType () throws Exception {
69+ String sql = "WITH base AS (\n " +
70+ " SELECT transaction_uuid, client_key, errors,\n " +
71+ " array_filter(request_attributes, x -> x.name = 'unitId')[1].value AS room_id,\n " +
72+ " array_filter(request_attributes, x -> x.name = 'ratePlanId')[1].value AS rate_plan_id\n " +
73+ " FROM conn_event\n " +
74+ "),\n " +
75+ "errs AS (\n " +
76+ " SELECT b.transaction_uuid AS transaction_uuid, unnest.code AS code\n " +
77+ " FROM base b CROSS JOIN UNNEST(b.errors) AS unnest\n " +
78+ ")\n " +
79+ "SELECT LEFT(c.transaction_uuid, 50) AS tid, LEFT(c.client_key, 50) AS ck,\n " +
80+ " c.room_id, e.code\n " +
81+ "FROM base c INNER JOIN errs e ON e.transaction_uuid = c.transaction_uuid" ;
82+
83+ StatementBase statement = SqlParser .parse (sql ,
84+ connectContext .getSessionVariable ().getSqlMode ()).get (0 );
85+ Analyzer .analyze (statement , connectContext );
86+ QueryStatement query = (QueryStatement ) statement ;
87+
88+ // First pass: warms the lambda-argument cache on the shared AST (LambdaArgument.transformedOp).
89+ new RelationTransformer (new ColumnRefFactory (), connectContext ).transform (query .getQueryRelation ());
90+
91+ // Second pass over the SAME AST with a fresh factory, exactly what plan retry / MV
92+ // rewrite do. On buggy code the cached lambda-arg ids leak in and collide here.
93+ ColumnRefFactory factory2 = new ColumnRefFactory ();
94+ LogicalPlan plan2 = new RelationTransformer (factory2 , connectContext ).transform (query .getQueryRelation ());
95+
96+ List <ColumnRefOperator > all = new ArrayList <>();
97+ collectColumnRefs (plan2 .getRoot (), all );
98+
99+ // BUG signature: one id is owned by both a lambda argument and a normal column.
100+ Map <Integer , ColumnRefOperator > lambdaById = new HashMap <>();
101+ Map <Integer , ColumnRefOperator > scalarById = new HashMap <>();
102+ for (ColumnRefOperator ref : all ) {
103+ if (ref .getOpType () == OperatorType .LAMBDA_ARGUMENT ) {
104+ lambdaById .put (ref .getId (), ref );
105+ } else {
106+ scalarById .put (ref .getId (), ref );
107+ }
108+ }
109+ Set <Integer > collisions = new TreeSet <>(lambdaById .keySet ());
110+ collisions .retainAll (scalarById .keySet ());
111+ Assertions .assertTrue (collisions .isEmpty (),
112+ "lambda argument id collides with a scalar column id (lambda-arg id leaked across "
113+ + "ColumnRefFactory). colliding ids=" + collisions
114+ + ", lambda=" + lambdaById + ", scalar=" + scalarById );
115+
116+ // And the varchar scalar columns must never be re-typed as struct.
117+ for (ColumnRefOperator ref : all ) {
118+ if (("transaction_uuid" .equals (ref .getName ()) || "client_key" .equals (ref .getName ()))
119+ && ref .getOpType () != OperatorType .LAMBDA_ARGUMENT ) {
120+ Assertions .assertFalse (ref .getType ().isStructType (),
121+ ref .getName () + " should stay VARCHAR but is " + ref .getType ()
122+ + " — lambda arg id collision corrupted its type." );
123+ }
124+ }
125+ }
126+
127+ private static void collectColumnRefs (OptExpression expr , List <ColumnRefOperator > out ) {
128+ if (expr .getOp () instanceof LogicalProjectOperator ) {
129+ LogicalProjectOperator project = (LogicalProjectOperator ) expr .getOp ();
130+ for (Map .Entry <ColumnRefOperator , ScalarOperator > e : project .getColumnRefMap ().entrySet ()) {
131+ collectFromScalar (e .getKey (), out );
132+ collectFromScalar (e .getValue (), out );
133+ }
134+ }
135+ if (expr .getOp () instanceof LogicalJoinOperator ) {
136+ collectFromScalar (((LogicalJoinOperator ) expr .getOp ()).getOnPredicate (), out );
137+ }
138+ collectFromScalar (expr .getOp ().getPredicate (), out );
139+ if (expr .getOp ().getProjection () != null ) {
140+ for (ScalarOperator s : expr .getOp ().getProjection ().getColumnRefMap ().values ()) {
141+ collectFromScalar (s , out );
142+ }
143+ for (ColumnRefOperator c : expr .getOp ().getProjection ().getColumnRefMap ().keySet ()) {
144+ collectFromScalar (c , out );
145+ }
146+ }
147+ for (OptExpression child : expr .getInputs ()) {
148+ collectColumnRefs (child , out );
149+ }
150+ }
151+
152+ private static void collectFromScalar (ScalarOperator s , List <ColumnRefOperator > out ) {
153+ if (s == null ) {
154+ return ;
155+ }
156+ if (s instanceof ColumnRefOperator ) {
157+ out .add ((ColumnRefOperator ) s );
158+ }
159+ if (s instanceof LambdaFunctionOperator ) {
160+ LambdaFunctionOperator lambda = (LambdaFunctionOperator ) s ;
161+ for (ColumnRefOperator ref : lambda .getRefColumns ()) {
162+ out .add (ref );
163+ }
164+ // The CSE/reuse columnRefMap holds ColumnRefOperators (keys) and their defining
165+ // expressions (values) that live only in the map: the lambda body references the
166+ // keys, so the values are not reachable via getChildren()/getLambdaExpr().
167+ for (Map .Entry <ColumnRefOperator , ScalarOperator > e : lambda .getColumnRefMap ().entrySet ()) {
168+ collectFromScalar (e .getKey (), out );
169+ collectFromScalar (e .getValue (), out );
170+ }
171+ // lambdaExpr is traversed below via getChildren() (== [lambdaExpr]); don't recurse it here.
172+ }
173+ for (ScalarOperator child : s .getChildren ()) {
174+ collectFromScalar (child , out );
175+ }
176+ }
177+ }
0 commit comments