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}