quick-oxibooks-sql 0.3.0

A library to construct type-checked and safe SQL queries for Oxibooks.
Documentation
use std::fmt::Display;
use std::fmt::Write;

// Re-export the procedural macro
pub use quick_oxibooks_sql_macro::qb_sql;
use quickbooks_types::QBItem;

/// Struct representing a SQL-like query for `QuickBooks` entities
#[derive(Debug, PartialEq, Clone)]
pub struct Query<QB> {
    condition: Vec<WhereClause>,
    order: Vec<OrderClause>,
    limit: Option<Limit>,
    _phantom: std::marker::PhantomData<QB>,
}

impl<QB: QBItem> Default for Query<QB> {
    fn default() -> Self {
        Self::new()
    }
}

impl<QB: QBItem> Query<QB> {
    /// Create a new empty query
    #[must_use]
    pub fn new() -> Self {
        Query {
            condition: Vec::new(),
            order: Vec::new(),
            limit: None,
            _phantom: std::marker::PhantomData,
        }
    }

    /// Add a condition to the query
    ///
    /// # Safety
    /// This function is unsafe because it accepts a raw `WhereClause`.
    /// The caller must ensure that the `WhereClause` is valid and corresponds to the `QuickBooks` entity.
    #[must_use]
    pub unsafe fn condition(mut self, condition: WhereClause) -> Self {
        self.condition.push(condition);
        self
    }

    /// Add an order clause to the query
    ///
    /// # Safety
    /// This function is unsafe because it accepts a raw string slice as the field name.
    /// The caller must ensure that the field name is valid and corresponds to a field in the `QuickBooks` entity.
    #[must_use]
    pub unsafe fn order(mut self, field: &'static str, order: Order) -> Self {
        self.order.push(OrderClause { field, order });
        self
    }

    /// Set a limit on the number of results returned by the query
    #[must_use]
    pub fn limit(mut self, number: u32, offset: Option<u32>) -> Self {
        self.limit = Some(Limit { number, offset });
        self
    }

    /// Generate the query string
    #[must_use]
    pub fn query_string(&self) -> String {
        let mut query = format!("select * from {}", QB::name());

        if !self.condition.is_empty() {
            query.push_str(" where");
            for (i, cond) in self.condition.iter().enumerate() {
                if i > 0 {
                    query.push_str(" and");
                }
                cond.extend_query(&mut query);
            }
        }

        if !self.order.is_empty() {
            query.push_str(" order by");
            for (i, ord) in self.order.iter().enumerate() {
                if i > 0 {
                    query.push(',');
                }
                ord.extend_query(&mut query);
            }
        }

        if let Some(limit) = &self.limit {
            limit.extend_query(&mut query);
        }

        query
    }

    #[cfg(feature = "api")]
    /// Execute the query against the `QuickBooks` API, returning a vector of results or an error
    ///
    /// # Errors
    /// This function will return an error if the API request fails or if the response cannot be parsed.
    pub fn execute(
        &self,
        qb: &quick_oxibooks::QBContext,
        client: &ureq::Agent,
    ) -> Result<Vec<QB>, quick_oxibooks::error::APIError> {
        // Safety: The query has been constructed using the provided methods,
        // ensuring that it is valid for the QuickBooks entity QB.
        unsafe { quick_oxibooks::functions::query::qb_query_raw::<QB>(self, qb, client) }
    }
}

impl<QB: QBItem> std::fmt::Display for Query<QB> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.query_string())
    }
}

#[derive(Debug, PartialEq, Clone, Copy)]
struct Limit {
    number: u32,
    offset: Option<u32>,
}

impl Limit {
    fn extend_query(&self, query: &mut String) {
        write!(query, " LIMIT {}", self.number).unwrap();
        if let Some(offset) = self.offset {
            write!(query, " OFFSET {offset}").unwrap();
        }
    }
}

/// Struct representing an order clause in a query
#[derive(Debug, PartialEq, Clone)]
struct OrderClause {
    field: &'static str,
    order: Order,
}

impl OrderClause {
    fn extend_query(&self, query: &mut String) {
        write!(
            query,
            " {} {}",
            self.field,
            match self.order {
                Order::Asc => "ASC",
                Order::Desc => "DESC",
            }
        )
        .unwrap();
    }
}

/// Enum representing the order direction in a query
#[derive(Debug, PartialEq, Clone)]
pub enum Order {
    Asc,
    Desc,
}

/// Struct representing a where clause in a query
#[derive(Debug, PartialEq, Clone)]
pub struct WhereClause {
    pub field: &'static str,
    pub operator: Operator,
    pub values: Vec<String>,
}

impl WhereClause {
    /// Create a new where clause
    #[must_use]
    pub fn new(field: &'static str, operator: Operator) -> Self {
        Self {
            field,
            operator,
            values: Vec::new(),
        }
    }

    /// Add a value to the where clause
    #[must_use]
    pub fn add_value<T: Display>(mut self, value: T) -> Self {
        self.values.push(value.to_string());
        self
    }

    /// Add multiple values to the where clause from an iterator
    #[must_use]
    pub fn add_values<I, T>(mut self, values: I) -> Self
    where
        I: Iterator<Item = T>,
        T: Display,
    {
        self.values.extend(values.map(|v| v.to_string()));
        self
    }
}

impl WhereClause {
    fn extend_query(&self, query: &mut String) {
        let op_str = match self.operator {
            Operator::In => "IN",
            Operator::Like => "LIKE",
            Operator::Equal => "=",
            Operator::Less => "<",
            Operator::Greater => ">",
            Operator::LessEqual => "<=",
            Operator::GreaterEqual => ">=",
        };

        if self.operator == Operator::In {
            write!(query, " {} IN (", self.field).unwrap();
            for (i, value) in self.values.iter().enumerate() {
                if i > 0 {
                    query.push_str(", ");
                }
                write!(query, "'{value}'").unwrap();
            }
            query.push(')');
        } else {
            write!(query, " {} {} '{}'", self.field, op_str, self.values[0]).unwrap();
        }
    }
}

/// Enum representing the operators used in where clauses
#[derive(Debug, PartialEq, Clone)]
pub enum Operator {
    In,
    Like,
    Equal,
    Less,
    Greater,
    LessEqual,
    GreaterEqual,
}

#[cfg(test)]
mod tests {
    use super::*;
    use quickbooks_types::Customer;

    #[test]
    fn test_empty_query() {
        let query = qb_sql!(select * from Customer);
        assert_eq!(query.condition.len(), 0);
        assert_eq!(query.order.len(), 0);
        assert!(query.limit.is_none());
    }

    #[test]
    fn test_basic_query() {
        let query = qb_sql!(
            select * from Customer
            where display_name like "John%"
        );

        assert_eq!(query.condition.len(), 1);
        assert_eq!(query.condition[0].field, "DisplayName");
    }

    #[test]
    fn test_multiple_conditions() {
        let balance_min = 1000.0;
        let query = qb_sql!(
            select * from Customer
            where display_name like "John%"
            and balance >= balance_min
        );

        assert_eq!(query.condition.len(), 2);
    }

    #[test]
    fn test_order_by() {
        let query = qb_sql!(
            select * from Customer
            where display_name like "John%"
            order by display_name asc, balance desc
        );

        assert_eq!(query.order.len(), 2);
        assert_eq!(query.order[0].field, "DisplayName");
        assert_eq!(query.order[0].order, Order::Asc);
    }

    #[test]
    fn test_limit_and_offset() {
        let offset_val = 5;
        let query = qb_sql!(
            select * from Customer
            where display_name like "John%"
            limit 10 offset offset_val
        );

        assert!(query.limit.is_some());
        let limit = query.limit.unwrap();
        assert_eq!(limit.number, 10);
        assert_eq!(limit.offset, Some(5));
    }

    #[test]
    fn test_query_string_generation() {
        let query = qb_sql!(
            select * from Customer
            where display_name like "John%"
            and id in (1, 2, 3)
            and balance >= 1000.0
            order by display_name asc, balance desc
            limit 10 offset 5
        );

        let query_string = query.query_string();
        let expected = "select * from Customer where DisplayName LIKE 'John%' and Id IN ('1', '2', '3') and Balance >= '1000' order by DisplayName ASC, Balance DESC LIMIT 10 OFFSET 5";
        assert_eq!(query_string, expected);
    }

    #[test]
    fn test_in_operator() {
        let query = qb_sql!(
            select * from Customer
            where id in (1, 2, 3, 4, 5)
        );

        assert_eq!(query.condition.len(), 1);
        assert_eq!(query.condition[0].field, "Id");
        assert_eq!(query.condition[0].operator, Operator::In);
        assert_eq!(query.condition[0].values.len(), 5);

        let query_string = query.query_string();
        assert_eq!(
            query_string,
            "select * from Customer where Id IN ('1', '2', '3', '4', '5')"
        );
    }

    #[test]
    fn test_in_operator_with_strings() {
        let title1 = "Mr";
        let title2 = "Mrs";
        let query = qb_sql!(
            select * from Customer
            where title in (title1, title2, "Dr")
        );

        assert_eq!(query.condition.len(), 1);
        assert_eq!(query.condition[0].values.len(), 3);

        let query_string = query.query_string();
        assert_eq!(
            query_string,
            "select * from Customer where Title IN ('Mr', 'Mrs', 'Dr')"
        );
    }

    #[test]
    fn test_in_iterator() {
        let ids = vec![1, 2, 3, 4, 5];
        let query = qb_sql!(
            select * from Customer
            where id in (ids)
        );

        assert_eq!(query.condition.len(), 1);
        assert_eq!(query.condition[0].field, "Id");
        assert_eq!(query.condition[0].operator, Operator::In);
        assert_eq!(query.condition[0].values.len(), 5);

        let query_string = query.query_string();
        assert_eq!(
            query_string,
            "select * from Customer where Id IN ('1', '2', '3', '4', '5')"
        );
    }

    #[test]
    fn test_nested_fields() {
        let query = qb_sql!(
            select * from Customer
            where primary_email_addr.address like "%@example.com"
        );

        assert_eq!(query.condition.len(), 1);
        assert_eq!(query.condition[0].field, "PrimaryEmailAddr.Address");

        let query_string = query.query_string();
        assert_eq!(
            query_string,
            "select * from Customer where PrimaryEmailAddr.Address LIKE '%@example.com'"
        );
    }
}