Skip to main content

risingwave_sqlsmith/sqlreduce/
rules.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//! Rules-based SQL reduction system.
16//!
17//! This module defines reduction rules and operations for different AST node types,
18//! providing configurable reduction behavior for comprehensive SQL simplification.
19
20use std::cmp::Reverse;
21use std::collections::HashMap;
22
23use risingwave_sqlparser::ast::*;
24
25use crate::sqlreduce::path::{AstField, AstNode, AstPath, PathComponent};
26
27/// Defines what actions can be performed on an AST node during reduction.
28#[derive(Debug, Clone, Default)]
29pub struct ReductionRule {
30    /// Whether to try replacing this node with NULL/None
31    pub try_null: bool,
32    /// Attributes to descend into for further reduction
33    pub descend: Vec<AstField>,
34    /// Attributes that can be removed (set to None)
35    pub remove: Vec<AstField>,
36    /// Attributes whose children can be pulled up to replace this node
37    pub pullup: Vec<AstField>,
38    /// Attributes whose subtrees can replace this entire node
39    pub replace: Vec<AstField>,
40}
41
42/// Repository of reduction rules for different AST node types.
43/// Configures how different SQL constructs should be reduced.
44pub struct ReductionRules {
45    rules: HashMap<String, ReductionRule>,
46}
47
48impl Default for ReductionRules {
49    fn default() -> Self {
50        let mut rules = HashMap::new();
51
52        // SelectStmt rules (most important for SQL reduction)
53        // Dependency order (independent first, dependent later):
54        // 1. Selection (WHERE) - independent, doesn't affect other clauses
55        // 2. Having - semi-independent, requires GROUP BY but rarely referenced
56        // 3. GroupBy - dependent, SELECT may reference GROUP BY columns
57        // 4. Projection (SELECT list) - dependent, may reference GROUP BY
58        // 5. From - fundamental, almost everything depends on it
59        rules.insert(
60            "Select".to_owned(),
61            ReductionRule {
62                try_null: false,
63                descend: vec![
64                    AstField::Projection,
65                    AstField::From,
66                    AstField::Selection,
67                    AstField::GroupBy,
68                    AstField::Having,
69                ],
70                remove: vec![
71                    AstField::Selection,  // Try removing WHERE first (most independent)
72                    AstField::Having,     // Then HAVING
73                    AstField::Projection, // Then SELECT list
74                    AstField::From,       // Then FROM
75                    AstField::GroupBy,    // Then GROUP BY
76                ],
77                pullup: vec![],
78                replace: vec![],
79            },
80        );
81
82        // Query rules
83        rules.insert(
84            "Query".to_owned(),
85            ReductionRule {
86                try_null: false,
87                descend: vec![AstField::Body, AstField::With, AstField::OrderBy],
88                remove: vec![AstField::With, AstField::OrderBy],
89                pullup: vec![],
90                replace: vec![AstField::Body],
91            },
92        );
93
94        // WITH clause rules
95        rules.insert(
96            "With".to_owned(),
97            ReductionRule {
98                try_null: true,
99                descend: vec![AstField::CteTable],
100                remove: vec![],
101                pullup: vec![],
102                replace: vec![],
103            },
104        );
105
106        // CTE list rules
107        rules.insert(
108            "CteList".to_owned(),
109            ReductionRule {
110                try_null: false,
111                descend: vec![], // Individual CTEs accessed via index
112                remove: vec![],
113                pullup: vec![],
114                replace: vec![],
115            },
116        );
117
118        // CTE rules
119        rules.insert(
120            "Cte".to_owned(),
121            ReductionRule {
122                try_null: false,
123                descend: vec![AstField::CteInner],
124                remove: vec![],
125                pullup: vec![AstField::CteInner],
126                replace: vec![],
127            },
128        );
129
130        // Expression rules - focus on pullup for simplification
131        rules.insert(
132            "BinaryOp".to_owned(),
133            ReductionRule {
134                try_null: true,
135                descend: vec![],
136                remove: vec![],
137                pullup: vec![AstField::Left, AstField::Right],
138                replace: vec![],
139            },
140        );
141
142        rules.insert(
143            "Case".to_owned(),
144            ReductionRule {
145                try_null: true,
146                descend: vec![],
147                remove: vec![],
148                pullup: vec![AstField::Operand, AstField::ElseResult],
149                replace: vec![],
150            },
151        );
152
153        // Function call rules
154        rules.insert(
155            "Function".to_owned(),
156            ReductionRule {
157                try_null: true,
158                descend: vec![], // args is not modeled as an AstField in our enum
159                remove: vec![],
160                pullup: vec![],
161                replace: vec![],
162            },
163        );
164
165        // Subquery rules
166        rules.insert(
167            "Subquery".to_owned(),
168            ReductionRule {
169                try_null: true,
170                descend: vec![],
171                remove: vec![],
172                pullup: vec![],
173                replace: vec![AstField::Subquery],
174            },
175        );
176
177        // Constant rules
178        rules.insert(
179            "Value".to_owned(),
180            ReductionRule {
181                try_null: true,
182                descend: vec![],
183                remove: vec![],
184                pullup: vec![],
185                replace: vec![],
186            },
187        );
188
189        // List reduction rules for SQL collections
190        rules.insert(
191            "SelectItemList".to_owned(),
192            ReductionRule {
193                try_null: false,
194                descend: vec![],
195                remove: vec![],
196                pullup: vec![],
197                replace: vec![],
198            },
199        );
200
201        rules.insert(
202            "ExprList".to_owned(),
203            ReductionRule {
204                try_null: true,
205                descend: vec![],
206                remove: vec![],
207                pullup: vec![],
208                replace: vec![],
209            },
210        );
211
212        rules.insert(
213            "TableList".to_owned(),
214            ReductionRule {
215                try_null: false,
216                descend: vec![],
217                remove: vec![],
218                pullup: vec![],
219                replace: vec![],
220            },
221        );
222
223        rules.insert(
224            "OrderByList".to_owned(),
225            ReductionRule {
226                try_null: false,
227                descend: vec![],
228                remove: vec![],
229                pullup: vec![],
230                replace: vec![],
231            },
232        );
233
234        // JOIN-related rules for better JOIN handling
235        rules.insert(
236            "TableWithJoins".to_owned(),
237            ReductionRule {
238                try_null: false,
239                descend: vec![AstField::Relation, AstField::Joins],
240                remove: vec![AstField::Joins],
241                pullup: vec![AstField::Relation],
242                replace: vec![AstField::Relation],
243            },
244        );
245
246        rules.insert(
247            "Join".to_owned(),
248            ReductionRule {
249                try_null: true,
250                descend: vec![AstField::Relation],
251                remove: vec![],
252                pullup: vec![AstField::Relation],
253                replace: vec![AstField::Relation],
254            },
255        );
256
257        rules.insert(
258            "TableFactor".to_owned(),
259            ReductionRule {
260                try_null: true, // Allow trying NULL for derived tables
261                descend: vec![AstField::Subquery],
262                remove: vec![AstField::Subquery], // Allow removing subquery entirely
263                pullup: vec![AstField::Subquery],
264                replace: vec![AstField::Subquery],
265            },
266        );
267
268        rules.insert(
269            "JoinList".to_owned(),
270            ReductionRule {
271                try_null: true, // Allow trying NULL for join lists
272                descend: vec![],
273                remove: vec![], // Remove individual joins through list operations
274                pullup: vec![],
275                replace: vec![],
276            },
277        );
278
279        Self { rules }
280    }
281}
282
283impl ReductionRules {
284    /// Get the reduction rule for a specific node type.
285    pub fn get_rule(&self, node_type: &str) -> Option<&ReductionRule> {
286        self.rules.get(node_type)
287    }
288
289    /// Get the node type string for an AST node.
290    pub fn get_node_type(node: &AstNode) -> String {
291        match node {
292            AstNode::Statement(_) => "Statement".to_owned(),
293            AstNode::Query(_) => "Query".to_owned(),
294            AstNode::Select(_) => "Select".to_owned(),
295            AstNode::Expr(expr) => match expr {
296                Expr::BinaryOp { .. } => "BinaryOp".to_owned(),
297                Expr::Case { .. } => "Case".to_owned(),
298                Expr::Function { .. } => "Function".to_owned(),
299                Expr::Subquery(_) => "Subquery".to_owned(),
300                Expr::Value(_) => "Value".to_owned(),
301                _ => "Expr".to_owned(),
302            },
303            AstNode::SelectItem(_) => "SelectItem".to_owned(),
304            AstNode::TableWithJoins(_) => "TableWithJoins".to_owned(),
305            AstNode::Join(_) => "Join".to_owned(),
306            AstNode::TableFactor(_) => "TableFactor".to_owned(),
307            AstNode::OrderByExpr(_) => "OrderByExpr".to_owned(),
308            AstNode::With(_) => "With".to_owned(),
309            AstNode::Cte(_) => "Cte".to_owned(),
310            AstNode::ExprList(_) => "ExprList".to_owned(),
311            AstNode::SelectItemList(_) => "SelectItemList".to_owned(),
312            AstNode::TableList(_) => "TableList".to_owned(),
313            AstNode::JoinList(_) => "JoinList".to_owned(),
314            AstNode::OrderByList(_) => "OrderByList".to_owned(),
315            AstNode::CteList(_) => "CteList".to_owned(),
316            AstNode::Option(_) => "Option".to_owned(),
317        }
318    }
319}
320
321/// Different types of reduction operations that can be applied.
322#[derive(Debug, Clone)]
323pub enum ReductionOperation {
324    /// Replace node with NULL/None
325    TryNull,
326    /// Remove a specific attribute (set to None)
327    Remove(AstField),
328    /// Pull up a subnode to replace this node
329    Pullup(AstField),
330    /// Replace this node with a subtree
331    Replace(AstField),
332    /// Remove an element from a list/tuple
333    RemoveListElement(usize),
334}
335
336impl ReductionOperation {
337    /// Get the priority score for this operation (higher = tried first).
338    /// Uses dependency-aware priority: independent components removed first,
339    /// then dependent components to minimize validation failures.
340    pub fn priority(&self) -> i32 {
341        match self {
342            // List element removal: Very safe, high success rate
343            // Removing a single item rarely breaks the query structure
344            ReductionOperation::RemoveListElement(_) => 100,
345
346            // Remove operations: Priority based on SQL dependencies
347            // Independent clauses get higher priority, dependent ones lower
348            ReductionOperation::Remove(field) => {
349                use AstField::*;
350                match field {
351                    // Tier 1: Fully independent clauses (no other clause depends on them)
352                    // WHERE is independent - can be removed without affecting structure
353                    Selection => 95,
354                    // ORDER BY is independent - only affects result ordering
355                    OrderBy => 94,
356                    // LIMIT/OFFSET are independent
357                    Limit | Offset => 93,
358                    // WITH/CTE can be removed if not referenced
359                    With => 92,
360
361                    // Tier 2: Semi-independent clauses
362                    // HAVING depends on aggregations, but often removable
363                    Having => 85,
364
365                    // Tier 3: Core dependent clauses (have circular dependencies)
366                    // GROUP BY and Projection (SELECT list) depend on each other
367                    // Removing these is riskier as they form the core query structure
368                    GroupBy => 70,
369                    Projection => 65,
370
371                    // FROM is most fundamental - almost everything depends on it
372                    From => 60,
373
374                    // Other fields get default priority
375                    _ => 80,
376                }
377            }
378
379            // Replace with subtree: Medium risk, good success rate
380            // Simplifies structure while preserving a valid subtree
381            ReductionOperation::Replace(_) => 55,
382
383            // Pullup: Medium-low risk, variable success rate
384            // Depends heavily on context compatibility
385            ReductionOperation::Pullup(_) => 40,
386
387            // Try NULL: High risk, lower success rate
388            // Often breaks type constraints or NOT NULL requirements
389            ReductionOperation::TryNull => 20,
390        }
391    }
392}
393
394/// A reduction candidate: a path to a node and the operation to apply.
395#[derive(Debug, Clone)]
396pub struct ReductionCandidate {
397    pub path: AstPath,
398    pub operation: ReductionOperation,
399}
400
401/// Generate all possible reduction candidates for a given AST.
402/// Systematically creates all viable reduction operations.
403pub fn generate_reduction_candidates(
404    root: &AstNode,
405    rules: &ReductionRules,
406    paths: &[AstPath],
407) -> Vec<ReductionCandidate> {
408    let mut candidates = Vec::new();
409
410    tracing::debug!("Generating reduction candidates for {} paths", paths.len());
411
412    for (path_idx, path) in paths.iter().enumerate() {
413        if let Some(node) = crate::sqlreduce::path::get_node_at_path(root, path) {
414            let node_type = ReductionRules::get_node_type(&node);
415            let path_str = crate::sqlreduce::path::display_ast_path(path);
416
417            tracing::debug!("Path {}: {} ({})", path_idx, path_str, node_type);
418
419            // Handle list/tuple removals (most important for reduction)
420            // Generate in reverse order so batch processing works correctly:
421            // removing higher indices first doesn't affect lower indices
422            match &node {
423                AstNode::SelectItemList(items) if items.len() > 1 => {
424                    tracing::debug!(
425                        "Adding {} RemoveListElement candidates for SelectItemList (reverse order)",
426                        items.len()
427                    );
428                    for i in (0..items.len()).rev() {
429                        candidates.push(ReductionCandidate {
430                            path: path.clone(),
431                            operation: ReductionOperation::RemoveListElement(i),
432                        });
433                    }
434                }
435                AstNode::ExprList(exprs) if exprs.len() > 1 => {
436                    tracing::debug!(
437                        "Adding {} RemoveListElement candidates for ExprList (reverse order)",
438                        exprs.len()
439                    );
440                    for i in (0..exprs.len()).rev() {
441                        candidates.push(ReductionCandidate {
442                            path: path.clone(),
443                            operation: ReductionOperation::RemoveListElement(i),
444                        });
445                    }
446                }
447                AstNode::TableList(tables) if tables.len() > 1 => {
448                    tracing::debug!(
449                        "Adding {} RemoveListElement candidates for TableList (reverse order)",
450                        tables.len()
451                    );
452                    for i in (0..tables.len()).rev() {
453                        candidates.push(ReductionCandidate {
454                            path: path.clone(),
455                            operation: ReductionOperation::RemoveListElement(i),
456                        });
457                    }
458                }
459                AstNode::OrderByList(orders) if orders.len() > 1 => {
460                    tracing::debug!(
461                        "Adding {} RemoveListElement candidates for OrderByList (reverse order)",
462                        orders.len()
463                    );
464                    for i in (0..orders.len()).rev() {
465                        candidates.push(ReductionCandidate {
466                            path: path.clone(),
467                            operation: ReductionOperation::RemoveListElement(i),
468                        });
469                    }
470                }
471                _ => {}
472            }
473
474            // Apply rule-based reductions
475            if let Some(rule) = rules.get_rule(&node_type) {
476                let mut rule_candidates = 0;
477
478                // Try null replacement
479                if rule.try_null {
480                    candidates.push(ReductionCandidate {
481                        path: path.clone(),
482                        operation: ReductionOperation::TryNull,
483                    });
484                    rule_candidates += 1;
485                }
486
487                // Try attribute removal
488                for attr in &rule.remove {
489                    candidates.push(ReductionCandidate {
490                        path: path.clone(),
491                        operation: ReductionOperation::Remove(attr.clone()),
492                    });
493                    rule_candidates += 1;
494                }
495
496                // Try pullup operations
497                for attr in &rule.pullup {
498                    candidates.push(ReductionCandidate {
499                        path: path.clone(),
500                        operation: ReductionOperation::Pullup(attr.clone()),
501                    });
502                    rule_candidates += 1;
503                }
504
505                // Try replace operations
506                for attr in &rule.replace {
507                    candidates.push(ReductionCandidate {
508                        path: path.clone(),
509                        operation: ReductionOperation::Replace(attr.clone()),
510                    });
511                    rule_candidates += 1;
512                }
513
514                if rule_candidates > 0 {
515                    tracing::debug!(
516                        "Added {} rule-based candidates for {}",
517                        rule_candidates,
518                        node_type
519                    );
520                }
521            } else {
522                tracing::debug!("No rules found for node type: {}", node_type);
523            }
524        }
525    }
526
527    tracing::debug!(
528        "Generated {} total candidates from {} paths (before sorting)",
529        candidates.len(),
530        paths.len()
531    );
532
533    // Sort candidates by dependency-aware priority (higher priority first)
534    // Independent clauses (WHERE, ORDER BY) tried first, then dependent ones (SELECT, GROUP BY)
535    candidates.sort_by_key(|c| Reverse(c.operation.priority()));
536
537    tracing::debug!(
538        "Sorted candidates by dependency-aware priority - first 10: {:?}",
539        candidates
540            .iter()
541            .take(10)
542            .map(|c| {
543                let op_desc = match &c.operation {
544                    ReductionOperation::Remove(field) => format!("Remove({:?})", field),
545                    op => format!("{:?}", op),
546                };
547                (c.operation.priority(), op_desc)
548            })
549            .collect::<Vec<_>>()
550    );
551
552    candidates
553}
554
555/// Apply a reduction operation to an AST node.
556/// Returns the new AST root if the operation was successful.
557pub fn apply_reduction_operation(
558    root: &AstNode,
559    candidate: &ReductionCandidate,
560) -> Option<AstNode> {
561    use crate::sqlreduce::path::{display_ast_path, get_node_at_path, set_node_at_path};
562
563    tracing::debug!(
564        "apply_reduction_operation: Trying to apply {:?} at path {}",
565        candidate.operation,
566        display_ast_path(&candidate.path)
567    );
568
569    let target_node = get_node_at_path(root, &candidate.path);
570    if target_node.is_none() {
571        tracing::debug!(
572            "apply_reduction_operation: Failed to get node at path {}",
573            display_ast_path(&candidate.path)
574        );
575        return None;
576    }
577    let target_node = target_node?;
578
579    match &candidate.operation {
580        ReductionOperation::TryNull => {
581            // Replace with NULL expression
582            let null_expr = AstNode::Expr(Expr::Value(Value::Null));
583            set_node_at_path(root, &candidate.path, Some(null_expr))
584        }
585
586        ReductionOperation::Remove(field) => {
587            // Remove an attribute (set to None)
588            let attr_path = [
589                candidate.path.clone(),
590                vec![PathComponent::field(field.clone())],
591            ]
592            .concat();
593            tracing::debug!(
594                "apply_reduction_operation: Removing attribute '{}' at path {}",
595                field.to_string(),
596                display_ast_path(&attr_path)
597            );
598
599            let result = set_node_at_path(root, &attr_path, None);
600            if result.is_none() {
601                tracing::debug!(
602                    "apply_reduction_operation: Failed to remove attribute '{}'",
603                    field.to_string()
604                );
605            } else {
606                tracing::debug!(
607                    "apply_reduction_operation: Successfully removed attribute '{}'",
608                    field.to_string()
609                );
610            }
611            result
612        }
613
614        ReductionOperation::Pullup(field) => {
615            // Pull up a subnode to replace the current node
616            let attr_path = [
617                candidate.path.clone(),
618                vec![PathComponent::field(field.clone())],
619            ]
620            .concat();
621            if let Some(subnode) = get_node_at_path(root, &attr_path) {
622                set_node_at_path(root, &candidate.path, Some(subnode))
623            } else {
624                None
625            }
626        }
627
628        ReductionOperation::Replace(field) => {
629            // Replace current node with a subtree
630            let attr_path = [
631                candidate.path.clone(),
632                vec![PathComponent::field(field.clone())],
633            ]
634            .concat();
635            tracing::debug!(
636                "apply_reduction_operation: Replacing with attribute '{}' from path {}",
637                field.to_string(),
638                display_ast_path(&attr_path)
639            );
640
641            if let Some(subtree) = get_node_at_path(root, &attr_path) {
642                tracing::debug!("apply_reduction_operation: Found subtree for replacement");
643                let result = set_node_at_path(root, &candidate.path, Some(subtree));
644                if result.is_none() {
645                    tracing::debug!("apply_reduction_operation: Failed to set replacement subtree");
646                } else {
647                    tracing::debug!(
648                        "apply_reduction_operation: Successfully replaced with subtree"
649                    );
650                }
651                result
652            } else {
653                tracing::debug!(
654                    "apply_reduction_operation: No subtree found at path {}",
655                    display_ast_path(&attr_path)
656                );
657                None
658            }
659        }
660
661        ReductionOperation::RemoveListElement(index) => {
662            // Remove an element from a list
663            match target_node {
664                AstNode::SelectItemList(mut items) => {
665                    if *index < items.len() && items.len() > 1 {
666                        items.remove(*index);
667                        set_node_at_path(
668                            root,
669                            &candidate.path,
670                            Some(AstNode::SelectItemList(items)),
671                        )
672                    } else {
673                        None
674                    }
675                }
676                AstNode::ExprList(mut exprs) => {
677                    if *index < exprs.len() && exprs.len() > 1 {
678                        exprs.remove(*index);
679                        set_node_at_path(root, &candidate.path, Some(AstNode::ExprList(exprs)))
680                    } else {
681                        None
682                    }
683                }
684                AstNode::TableList(mut tables) => {
685                    if *index < tables.len() && tables.len() > 1 {
686                        tables.remove(*index);
687                        set_node_at_path(root, &candidate.path, Some(AstNode::TableList(tables)))
688                    } else {
689                        None
690                    }
691                }
692                AstNode::OrderByList(mut orders) => {
693                    if *index < orders.len() && orders.len() > 1 {
694                        orders.remove(*index);
695                        set_node_at_path(root, &candidate.path, Some(AstNode::OrderByList(orders)))
696                    } else {
697                        None
698                    }
699                }
700                _ => None,
701            }
702        }
703    }
704}
705
706#[cfg(test)]
707mod tests {
708    use risingwave_sqlparser::parser::Parser;
709
710    use super::*;
711    use crate::sqlreduce::path::{enumerate_reduction_paths, statement_to_ast_node};
712
713    #[test]
714    fn test_reduction_candidates() {
715        let sql = "SELECT a, b, c FROM t1, t2;";
716        let parsed = Parser::parse_sql(sql).expect("Failed to parse SQL");
717        let stmt = &parsed[0];
718        let ast_node = statement_to_ast_node(stmt);
719
720        let paths = enumerate_reduction_paths(&ast_node, vec![]);
721        let rules = ReductionRules::default();
722        let candidates = generate_reduction_candidates(&ast_node, &rules, &paths);
723
724        // Should generate multiple candidates for removing SELECT items, FROM tables, etc.
725        assert!(!candidates.is_empty());
726        println!(
727            "Generated {} candidates for complex query",
728            candidates.len()
729        );
730    }
731
732    #[test]
733    fn test_list_element_removal() {
734        let sql = "SELECT a, b, c FROM t;";
735        let parsed = Parser::parse_sql(sql).expect("Failed to parse SQL");
736        let stmt = &parsed[0];
737        let ast_node = statement_to_ast_node(stmt);
738
739        let paths = enumerate_reduction_paths(&ast_node, vec![]);
740        let rules = ReductionRules::default();
741        let candidates = generate_reduction_candidates(&ast_node, &rules, &paths);
742
743        // Find candidates that remove SELECT list elements
744        let list_removals: Vec<_> = candidates
745            .iter()
746            .filter(|c| matches!(c.operation, ReductionOperation::RemoveListElement(_)))
747            .collect();
748
749        // Should find 3 list removal candidates (for a, b, c)
750        assert!(list_removals.len() == 3);
751        println!(
752            "✓ Found {} list element removal candidates as expected",
753            list_removals.len()
754        );
755    }
756}