risingwave_sqlsmith/sqlreduce/reducer.rs
1// Copyright 2025 RisingWave Labs
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// http://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//! SQL reducer that applies a sequence of reduction passes to simplify a failing SQL query
16//! while preserving its failure behavior.
17//!
18//! The reducer works in fixed-point fashion: each transformation pass is applied iteratively
19//! until no further simplification is possible. Only transformations that preserve failure
20//! behavior (as checked by the `Checker`) are accepted.
21//!
22//! This is used for SQL test case minimization, debugging, or fuzzing feedback reduction.
23
24use anyhow::{Result, anyhow};
25
26use crate::parse_sql;
27use crate::sqlreduce::checker::Checker;
28use crate::sqlreduce::passes::pullup::{
29 ArrayPullup, BinaryOperatorPullup, CasePullup, RowPullup, SetOperationPullup,
30};
31use crate::sqlreduce::passes::remove::{
32 FromRemove, GroupByRemove, HavingRemove, OrderByRemove, SelectItemRemove, WhereRemove,
33};
34use crate::sqlreduce::passes::replace::{NullReplace, ScalarReplace};
35use crate::sqlreduce::passes::{Strategy, Transform};
36
37pub struct Reducer {
38 transforms: Vec<Box<dyn Transform>>,
39 checker: Checker,
40 strategy: Strategy,
41}
42
43impl Reducer {
44 pub fn new(checker: Checker, strategy: Strategy) -> Self {
45 let transforms: Vec<Box<dyn Transform>> = vec![
46 Box::new(ScalarReplace),
47 Box::new(NullReplace),
48 Box::new(GroupByRemove),
49 Box::new(OrderByRemove),
50 Box::new(WhereRemove),
51 Box::new(FromRemove),
52 Box::new(SelectItemRemove),
53 Box::new(BinaryOperatorPullup),
54 Box::new(CasePullup),
55 Box::new(RowPullup),
56 Box::new(ArrayPullup),
57 Box::new(SetOperationPullup),
58 Box::new(HavingRemove),
59 ];
60 Self {
61 transforms,
62 checker,
63 strategy,
64 }
65 }
66
67 /// Perform reduction on a SQL input containing multiple statements,
68 /// where only the **last** statement is considered the failing one.
69 ///
70 /// The reducer:
71 /// 1. Executes all preceding statements using the checker client.
72 /// 2. Verifies that the last statement indeed fails (self-check).
73 /// 3. Applies transformation passes to simplify the failing query
74 /// while preserving the failure behavior.
75 /// 4. Returns the reduced failing SQL query as a string.
76 ///
77 /// # Arguments
78 /// - `sql`: A SQL script with multiple statements (e.g., setup + failing query).
79 ///
80 /// # Returns
81 /// - A simplified version of the last statement that still fails in the same way.
82 /// - The preceding statements are also returned as a string.
83 ///
84 /// # Errors
85 /// - Returns an error if SQL parsing fails or if no statements are found.
86 /// - Panics if the checker fails to validate failure preservation on the original failing query.
87 pub async fn reduce(&mut self, sql: &str) -> Result<String> {
88 tracing::info!("Preparing schema...");
89 self.checker.prepare_schema().await;
90
91 tracing::info!("Starting reduction...");
92 let sql_statements = parse_sql(sql);
93
94 let (failing_query, proceeding_stmts) = sql_statements
95 .split_last()
96 .ok_or_else(|| anyhow!("No SQL statements found"))?;
97
98 for s in proceeding_stmts {
99 tracing::info!("Executing preceding statement: {}", s);
100 self.checker.client.simple_query(&s.to_string()).await?;
101 }
102
103 if !self
104 .checker
105 .is_failure_preserved(&failing_query.to_string(), &failing_query.to_string())
106 .await
107 {
108 tracing::error!("Checker failed: failing query does not fail on itself");
109 panic!("There is a bug in the checker!")
110 }
111
112 tracing::info!("Beginning fixed-point reduction...");
113 let reduced_sql = self
114 .reduce_until_fixed_point(&failing_query.to_string())
115 .await;
116
117 tracing::info!("Reduction complete.");
118
119 let mut reduced_sqls = String::new();
120 for s in proceeding_stmts {
121 reduced_sqls.push_str(&s.to_string());
122 reduced_sqls.push_str(";\n");
123 }
124 reduced_sqls.push_str(&reduced_sql);
125 reduced_sqls.push_str(";\n");
126
127 // Drop the schema after the reduction is complete.
128 self.checker.drop_schema().await;
129
130 Ok(reduced_sqls)
131 }
132
133 /// Apply all transformations in a fixed-point loop until no further reduction is possible.
134 ///
135 /// For each transformation:
136 /// - Iterate over all applicable reduction points.
137 /// - If a smaller version of the query is found and passes the failure check,
138 /// accept it and continue from that point.
139 ///
140 /// The process continues until a global fixed point is reached (i.e., no transformation
141 /// makes progress on any part of the SQL).
142 ///
143 /// # Arguments
144 /// - `sql`: The SQL string (usually the failing query) to reduce.
145 ///
146 /// # Returns
147 /// - A reduced SQL string (still failing) that is minimized w.r.t the current passes.
148 async fn reduce_until_fixed_point(&mut self, sql: &str) -> String {
149 let mut global_fixed_point = false;
150 let mut ast = parse_sql(sql)[0].clone();
151 let mut iteration = 0;
152
153 while !global_fixed_point {
154 iteration += 1;
155 tracing::info!("Global iteration {} starting", iteration);
156 global_fixed_point = true;
157 for trans in &self.transforms {
158 let mut local_fixed_point = false;
159 let mut idx = 0;
160 let mut sql_len = ast.to_string().len();
161 tracing::info!("Applying transform: {}", trans.name());
162
163 while !local_fixed_point {
164 local_fixed_point = true;
165 tracing::info!(" Transform iteration starting at index {}", idx);
166
167 let items = trans.transform(ast.clone(), idx, self.strategy.clone());
168
169 for (new_ast, i) in items {
170 let ast_sql = ast.to_string();
171 let new_ast_sql = new_ast.to_string();
172 tracing::info!(" SQL changes from \n{} \n to \n{}", ast_sql, new_ast_sql);
173 if new_ast_sql.len() < sql_len {
174 tracing::info!(
175 " Candidate reduction found: len {} → {}",
176 sql_len,
177 new_ast_sql.len()
178 );
179 if self
180 .checker
181 .is_failure_preserved(&ast_sql, &new_ast_sql)
182 .await
183 {
184 tracing::info!(
185 " Valid reduction applied at index {} ({} → {})",
186 i,
187 sql_len,
188 new_ast_sql.len()
189 );
190 ast = new_ast;
191 idx = i;
192 local_fixed_point = false;
193 global_fixed_point = false;
194 sql_len = new_ast_sql.len();
195 break;
196 } else {
197 tracing::info!(" Reduction not valid; failure not preserved.");
198 }
199 }
200 }
201 }
202 }
203 tracing::info!("Global iteration {} complete", iteration);
204 }
205
206 ast.to_string()
207 }
208}