hayson-engine 0.8.0

Storage engine for hayson document databases — the embeddable core behind hayson-server, hayson-cli and hayson-browser
Documentation
use serde_json::Value;

use crate::registry::fulltext_index::SearchMode;

use super::aggregate::AggregateSpec;
use super::planner::{QuerySpec, SortDirection};
use super::predicates::{Combine, ComparisonOp, PredicateNode};

/// Collects the predicates of one bracket. Everything added inside it combines by the bracket's
/// own operator; nesting is unlimited. Mirrors the TS `QueryGroupBuilder`.
pub struct PredicateGroupBuilder {
    nodes: Vec<PredicateNode>,
}

impl PredicateGroupBuilder {
    fn new() -> Self {
        PredicateGroupBuilder { nodes: Vec::new() }
    }

    pub fn r#where(&mut self, field: impl Into<String>, op: ComparisonOp, value: Value) -> &mut Self {
        self.nodes.push(PredicateNode::leaf(field, op, value));
        self
    }

    pub fn where_any(&mut self, build: impl FnOnce(&mut PredicateGroupBuilder)) -> &mut Self {
        self.nest(Combine::Or, build)
    }

    pub fn where_all(&mut self, build: impl FnOnce(&mut PredicateGroupBuilder)) -> &mut Self {
        self.nest(Combine::And, build)
    }

    fn nest(&mut self, combine: Combine, build: impl FnOnce(&mut PredicateGroupBuilder)) -> &mut Self {
        let mut bracket = PredicateGroupBuilder::new();
        build(&mut bracket);
        if !bracket.nodes.is_empty() {
            self.nodes.push(PredicateNode::Group {
                combine,
                nodes: bracket.nodes,
            });
        }
        self
    }

    fn into_nodes(self) -> Vec<PredicateNode> {
        self.nodes
    }
}

/// Fluent, programmatic construction of a `QuerySpec` — the typed counterpart of the WHERE-clause
/// text parsed by `super::parser::parse_query`. `SELECT`/`FROM`/`JOIN`/`MATCH` from ADR-0009 are a
/// separate, larger piece and still not covered by either front.
// TODO: SELECT/FROM/JOIN/MATCH and bound params (ADR-0009) — out of scope.
pub struct QueryBuilder {
    root: PredicateGroupBuilder,
    search: Option<(String, SearchMode)>,
    order_by: Option<(String, SortDirection)>,
    limit: Option<usize>,
    offset: Option<usize>,
    aggregate: AggregateSpec,
}

impl QueryBuilder {
    pub fn new() -> Self {
        QueryBuilder {
            root: PredicateGroupBuilder::new(),
            search: None,
            order_by: None,
            limit: None,
            offset: None,
            aggregate: AggregateSpec::default(),
        }
    }

    pub fn r#where(mut self, field: impl Into<String>, op: ComparisonOp, value: Value) -> Self {
        self.root.r#where(field, op, value);
        self
    }

    pub fn where_any(mut self, build: impl FnOnce(&mut PredicateGroupBuilder)) -> Self {
        self.root.where_any(build);
        self
    }

    pub fn where_all(mut self, build: impl FnOnce(&mut PredicateGroupBuilder)) -> Self {
        self.root.where_all(build);
        self
    }

    pub fn search(mut self, text: impl Into<String>, mode: SearchMode) -> Self {
        self.search = Some((text.into(), mode));
        self
    }

    /// Sets the sort key. One key only, and a second call replaces the first — the TS
    /// `QueryBuilder.orderBy` this mirrors keeps a single key too.
    pub fn order_by(mut self, field: impl Into<String>, direction: SortDirection) -> Self {
        self.order_by = Some((field.into(), direction));
        self
    }

    pub fn limit(mut self, limit: usize) -> Self {
        self.limit = Some(limit);
        self
    }

    pub fn offset(mut self, offset: usize) -> Self {
        self.offset = Some(offset);
        self
    }

    pub fn count(mut self) -> Self {
        self.aggregate.count = true;
        self
    }

    pub fn sum(mut self, field: impl Into<String>) -> Self {
        self.aggregate.sum.push(field.into());
        self
    }

    pub fn avg(mut self, field: impl Into<String>) -> Self {
        self.aggregate.avg.push(field.into());
        self
    }

    pub fn min(mut self, field: impl Into<String>) -> Self {
        self.aggregate.min.push(field.into());
        self
    }

    pub fn max(mut self, field: impl Into<String>) -> Self {
        self.aggregate.max.push(field.into());
        self
    }

    pub fn group_by(mut self, field: impl Into<String>) -> Self {
        self.aggregate.group_by = Some(field.into());
        self
    }

    pub fn spec(self) -> QuerySpec {
        let mut spec = QuerySpec::new(PredicateNode::Group {
            combine: Combine::And,
            nodes: self.root.into_nodes(),
        });
        if let Some((text, mode)) = self.search {
            spec = spec.with_search(text, mode);
        }
        if let Some((field, direction)) = self.order_by {
            spec = spec.with_order_by(field, direction);
        }
        if let Some(limit) = self.limit {
            spec = spec.with_limit(limit);
        }
        if let Some(offset) = self.offset {
            spec = spec.with_offset(offset);
        }
        spec
    }

    /// Splits the builder into its `QuerySpec` (predicates/search) and the `AggregateSpec`
    /// accumulated by `count`/`sum`/`avg`/`min`/`max` — the two halves `Table::aggregate` expects.
    pub fn build_aggregate(mut self) -> (QuerySpec, AggregateSpec) {
        let aggregate = std::mem::take(&mut self.aggregate);
        (self.spec(), aggregate)
    }
}

impl Default for QueryBuilder {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::*;

    #[test]
    fn builds_a_flat_conjunction() {
        let spec = QueryBuilder::new()
            .r#where("role", ComparisonOp::Eq, json!("admin"))
            .r#where("age", ComparisonOp::Gt, json!(30))
            .spec();

        match spec.root {
            PredicateNode::Group {
                combine: Combine::And,
                nodes,
            } => assert_eq!(nodes.len(), 2),
            _ => panic!("expected a top-level conjunction"),
        }
    }

    #[test]
    fn nests_an_and_bracket_inside_an_or_bracket() {
        let spec = QueryBuilder::new()
            .where_any(|group| {
                group
                    .r#where("role", ComparisonOp::Eq, json!("admin"))
                    .where_all(|inner| {
                        inner.r#where("role", ComparisonOp::Eq, json!("user")).r#where(
                            "age",
                            ComparisonOp::Gt,
                            json!(35),
                        );
                    });
            })
            .spec();

        let PredicateNode::Group { nodes, .. } = spec.root else {
            panic!("expected root group")
        };
        assert_eq!(nodes.len(), 1);
        let PredicateNode::Group {
            combine: Combine::Or,
            nodes: or_nodes,
        } = &nodes[0]
        else {
            panic!("expected an or bracket")
        };
        assert_eq!(or_nodes.len(), 2);
        assert!(matches!(
            or_nodes[1],
            PredicateNode::Group {
                combine: Combine::And,
                ..
            }
        ));
    }

    #[test]
    fn drops_an_empty_bracket_instead_of_keeping_a_hollow_node() {
        let spec = QueryBuilder::new().where_any(|_group| {}).spec();

        let PredicateNode::Group { nodes, .. } = spec.root else {
            panic!("expected root group")
        };
        assert!(nodes.is_empty());
    }
}