selene-db-gql 1.3.0

ISO/IEC 39075:2024 GQL parser, planner, optimizer, and executor for selene-db.
Documentation
//! Boolean conjunction splitting rule.

use crate::{
    BinaryOp, GqlType, ValueExpr,
    analyze::AnalyzedType,
    plan::{
        BindingDef, ExecutionPlan, FilterPredicate, FilterPredicateKind, PipelineOp,
        optimize::{OptimizeContext, Rule, Transformed, binding_refs, walk},
    },
};

/// Split boolean `AND` filters into adjacent predicates.
pub struct AndSplitting;

impl Rule for AndSplitting {
    fn name(&self) -> &'static str {
        "and_splitting"
    }

    fn rewrite(
        &self,
        mut plan: ExecutionPlan,
        ctx: &OptimizeContext<'_>,
    ) -> Transformed<ExecutionPlan> {
        let mut changed = false;
        // The pattern bindings are only read while splitting an actual `AND`
        // filter (to recollect per-conjunct binding-refs). When neither the
        // pattern filters nor the pipeline filters contain a top-level `AND`,
        // splitting is a no-op, so skip cloning the bindings entirely.
        let pattern_bindings = if plan_has_and_filter(&plan) {
            plan.pattern_plan
                .as_ref()
                .map(|pattern| pattern.bindings.clone())
                .unwrap_or_default()
        } else {
            Vec::new()
        };
        if plan.pattern_plan.is_some() {
            let mut filters = {
                let pattern = plan.pattern_plan.as_mut().expect("pattern checked");
                std::mem::take(&mut pattern.filters)
            };
            changed |= split_predicate_vec(&mut filters, &pattern_bindings, &mut plan);
            plan.pattern_plan.as_mut().expect("pattern checked").filters = filters;
        }
        let mut pipeline = std::mem::take(&mut plan.pipeline);
        changed |= split_pipeline_filters(&mut pipeline, &pattern_bindings, &mut plan);
        plan.pipeline = pipeline;

        let nested = walk::recurse_rule_subplans(plan, self, ctx);
        changed |= nested.changed;
        Transformed {
            plan: nested.plan,
            changed,
        }
    }
}

/// True when the plan has at least one splittable (`AND`-expression) filter,
/// either in the leading pattern plan or in the top-level pipeline. Used to
/// skip the pattern-binding clone when splitting would be a no-op.
fn plan_has_and_filter(plan: &ExecutionPlan) -> bool {
    let pattern_has = plan
        .pattern_plan
        .as_ref()
        .is_some_and(|pattern| pattern.filters.iter().any(is_and_expression_filter));
    pattern_has
        || plan.pipeline.iter().any(|op| match op {
            PipelineOp::Filter(pred) => is_and_expression_filter(pred),
            _ => false,
        })
}

fn is_and_expression_filter(pred: &FilterPredicate) -> bool {
    pred.kind == FilterPredicateKind::Expression
        && matches!(
            pred.expr,
            ValueExpr::BinaryOp {
                op: BinaryOp::And,
                ..
            }
        )
}

fn split_pipeline_filters(
    pipeline: &mut Vec<PipelineOp>,
    bindings: &[BindingDef],
    plan: &mut ExecutionPlan,
) -> bool {
    let mut changed = false;
    let mut rewritten = Vec::with_capacity(pipeline.len());
    for op in pipeline.drain(..) {
        match op {
            PipelineOp::Filter(pred) => {
                let predicates = split_predicate(pred, bindings, plan);
                changed |= predicates.len() > 1;
                rewritten.extend(predicates.into_iter().map(PipelineOp::Filter));
            }
            other => rewritten.push(other),
        }
    }
    *pipeline = rewritten;
    changed
}

fn split_predicate_vec(
    predicates: &mut Vec<FilterPredicate>,
    bindings: &[BindingDef],
    plan: &mut ExecutionPlan,
) -> bool {
    let mut changed = false;
    let mut rewritten = Vec::with_capacity(predicates.len());
    for pred in predicates.drain(..) {
        let split = split_predicate(pred, bindings, plan);
        changed |= split.len() > 1;
        rewritten.extend(split);
    }
    *predicates = rewritten;
    changed
}

fn split_predicate(
    pred: FilterPredicate,
    bindings: &[BindingDef],
    plan: &mut ExecutionPlan,
) -> Vec<FilterPredicate> {
    if pred.kind != FilterPredicateKind::Expression {
        return vec![pred];
    }
    // Guard before the deep clone: only a top-level `AND` flattens to >1
    // predicate (a non-`AND` pushes exactly one, so `flatten_and` would return
    // `len <= 1`). Checking the discriminant first avoids cloning the whole
    // expression tree for the common single-predicate case.
    if !matches!(
        pred.expr,
        ValueExpr::BinaryOp {
            op: BinaryOp::And,
            ..
        }
    ) {
        return vec![pred];
    }
    let mut exprs = Vec::new();
    flatten_and(pred.expr.clone(), &mut exprs);
    if exprs.len() <= 1 {
        return vec![pred];
    }

    exprs
        .into_iter()
        .map(|expr| {
            let binding_refs = binding_refs::collect_binding_refs(&expr, bindings)
                .unwrap_or_else(|| pred.binding_refs.clone());
            FilterPredicate {
                span: expr.span(),
                expr,
                expr_id: plan.alloc_expr_id(),
                ty: AnalyzedType::Resolved(GqlType::Boolean),
                binding_refs,
                kind: FilterPredicateKind::Expression,
                index_consumed: false,
            }
        })
        .collect()
}

fn flatten_and(expr: ValueExpr, out: &mut Vec<ValueExpr>) {
    // Iterative depth-first flatten. `ValueExpr` carries a manual iterative
    // `Drop` (see `ast::expr`), so its child `Box<ValueExpr>`s cannot be moved
    // out of an owned node by a by-value `match` arm (E0509). Extract the
    // conjuncts in place with `mem::replace` and walk a worklist instead, which
    // also keeps the flatten itself non-recursive. A `lhs`-then-`rhs` push order
    // (rhs pushed first, popped last) preserves the original left-to-right
    // conjunct ordering in `out`.
    let mut stack = vec![expr];
    while let Some(mut expr) = stack.pop() {
        if let ValueExpr::BinaryOp {
            op: BinaryOp::And,
            lhs,
            rhs,
            ..
        } = &mut expr
        {
            let lhs = std::mem::replace(lhs.as_mut(), placeholder_expr());
            let rhs = std::mem::replace(rhs.as_mut(), placeholder_expr());
            stack.push(rhs);
            stack.push(lhs);
        } else {
            out.push(expr);
        }
    }
}

/// A childless placeholder swapped into an `AND` node's operand slots while its
/// real conjuncts are hoisted out (the node itself is then discarded).
fn placeholder_expr() -> ValueExpr {
    ValueExpr::Literal(crate::ast::expr::Literal::Null(crate::SourceSpan::default()))
}