bomboni_request 0.4.0

Utilities for working with API requests. Part of Bomboni library.
Documentation
//! # Search query.
//!
//! Utility for working with fuzzy search queries.

use crate::{
    filter::Filter,
    ordering::{Ordering, OrderingTerm},
    query::{
        error::{QueryError, QueryResult},
        page_token::{
            FilterPageToken, PageTokenBuilder, aes256::Aes256PageTokenBuilder,
            base64::Base64PageTokenBuilder, plain::PlainPageTokenBuilder, rsa::RsaPageTokenBuilder,
        },
        utility::{parse_query_filter, parse_query_ordering},
    },
    schema::{FunctionSchemaMap, Schema, SchemaMapped},
};

/// Represents a search query.
#[derive(Debug, Clone, PartialEq)]
pub struct SearchQuery<T = FilterPageToken> {
    /// Search query string.
    pub query: String,
    /// Page size.
    pub page_size: i32,
    /// Page token.
    pub page_token: Option<T>,
    /// Filter.
    pub filter: Filter,
    /// Ordering.
    pub ordering: Ordering,
}

/// Config for search query builder.
///
/// `primary_ordering_term` should probably never be `None`.
#[derive(Debug, Clone)]
pub struct SearchQueryConfig {
    /// Maximum query length.
    pub max_query_length: Option<usize>,
    /// Maximum page size.
    pub max_page_size: Option<i32>,
    /// Default page size.
    pub default_page_size: i32,
    /// Primary ordering term.
    pub primary_ordering_term: Option<OrderingTerm>,
    /// Maximum filter length.
    pub max_filter_length: Option<usize>,
    /// Maximum ordering length.
    pub max_ordering_length: Option<usize>,
}

/// Builder for search queries.
#[derive(Debug, Clone)]
pub struct SearchQueryBuilder<P: PageTokenBuilder> {
    schema: Schema,
    schema_functions: FunctionSchemaMap,
    options: SearchQueryConfig,
    page_token_builder: P,
}

/// Plain search query builder.
pub type PlainSearchQueryBuilder = SearchQueryBuilder<PlainPageTokenBuilder>;
/// AES256 search query builder.
pub type Aes256SearchQueryBuilder = SearchQueryBuilder<Aes256PageTokenBuilder>;
/// Base64 search query builder.
pub type Base64SearchQueryBuilder = SearchQueryBuilder<Base64PageTokenBuilder>;
/// RSA search query builder.
pub type RsaSearchQueryBuilder = SearchQueryBuilder<RsaPageTokenBuilder>;

impl SearchQuery {
    /// Creates salt for page token.
    pub fn make_salt(query: &str, page_size: i32) -> Vec<u8> {
        let mut salt = page_size.to_be_bytes().to_vec();
        salt.extend(query.as_bytes());
        salt
    }
}

impl Default for SearchQueryConfig {
    fn default() -> Self {
        Self {
            max_query_length: None,
            max_page_size: None,
            default_page_size: 20,
            primary_ordering_term: None,
            max_filter_length: None,
            max_ordering_length: None,
        }
    }
}

impl<P: PageTokenBuilder> SearchQueryBuilder<P> {
    /// Creates a new search query builder.
    pub const fn new(
        schema: Schema,
        schema_functions: FunctionSchemaMap,
        options: SearchQueryConfig,
        page_token_builder: P,
    ) -> Self {
        Self {
            schema,
            schema_functions,
            options,
            page_token_builder,
        }
    }

    /// Builds a search query.
    ///
    /// # Errors
    ///
    /// Will return [`QueryError::QueryTooLong`] if query exceeds maximum length.
    /// Will return [`QueryError::FilterTooLong`] if filter exceeds maximum length.
    /// Will return [`QueryError::FilterError`] if filter cannot be parsed or validated.
    /// Will return [`QueryError::OrderingTooLong`] if ordering exceeds maximum length.
    /// Will return [`QueryError::OrderingError`] if ordering cannot be parsed or validated.
    /// Will return [`QueryError::InvalidPageSize`] if page size is negative.
    /// Will return page token parsing errors from the underlying page token builder.
    pub fn build(
        &self,
        query: &str,
        page_size: Option<i32>,
        page_token: Option<&str>,
        filter: Option<&str>,
        ordering: Option<&str>,
    ) -> QueryResult<SearchQuery<P::PageToken>> {
        if matches!(self.options.max_query_length, Some(max) if query.len() > max) {
            return Err(QueryError::QueryTooLong);
        }

        let filter = parse_query_filter(
            filter,
            &self.schema,
            Some(&self.schema_functions),
            self.options.max_filter_length,
        )?;
        let mut ordering =
            parse_query_ordering(ordering, &self.schema, self.options.max_ordering_length)?;

        // Pre-insert primary ordering term.
        // This is needed for page tokens to work.
        if let Some(primary_ordering_term) = self.options.primary_ordering_term.as_ref()
            && ordering
                .iter()
                .all(|term| term.name != primary_ordering_term.name)
        {
            ordering.insert(0, primary_ordering_term.clone());
        }

        // Handle paging.
        let mut page_size = page_size.unwrap_or(self.options.default_page_size);
        if page_size < 0 {
            return Err(QueryError::InvalidPageSize);
        }
        if let Some(max_page_size) = self.options.max_page_size {
            // Intentionally clamp page size to max page size.
            if page_size > max_page_size {
                page_size = max_page_size;
            }
        }

        let page_token =
            if let Some(page_token) = page_token.filter(|page_token| !page_token.is_empty()) {
                Some(self.page_token_builder.parse(
                    &filter,
                    &ordering,
                    &SearchQuery::make_salt(query, page_size),
                    page_token,
                )?)
            } else {
                None
            };

        Ok(SearchQuery {
            query: query.into(),
            filter,
            ordering,
            page_size,
            page_token,
        })
    }

    /// Builds the next page token from the given query and next item.
    ///
    /// # Errors
    ///
    /// Will return page token building errors from the underlying page token builder.
    pub fn build_next_page_token<T: SchemaMapped>(
        &self,
        query: &SearchQuery<P::PageToken>,
        next_item: &T,
    ) -> QueryResult<String> {
        self.page_token_builder.build_next(
            &query.filter,
            &query.ordering,
            &SearchQuery::make_salt(&query.query, query.page_size),
            next_item,
        )
    }

    /// Gets the page token builder.
    pub const fn page_token_builder(&self) -> &P {
        &self.page_token_builder
    }
}

#[cfg(test)]
mod tests {
    use crate::{
        filter::error::FilterError,
        ordering::{OrderingDirection, error::OrderingError},
        query::page_token::plain::PlainPageTokenBuilder,
        testing::schema::UserItem,
    };

    use super::*;

    #[test]
    fn it_works() {
        let qb = get_query_builder();
        let query = qb
            .build(
                "abc",
                Some(10_000),
                None,
                Some("displayName = \"John\""),
                Some("age desc"),
            )
            .unwrap();
        assert_eq!(query.page_size, 20);
        assert_eq!(query.filter.to_string(), "displayName = \"John\"");
        assert_eq!(query.ordering.to_string(), "id desc, age desc");
    }

    #[test]
    fn errors() {
        let q = get_query_builder();
        assert_eq!(
            q.build(
                &("a".repeat(100)),
                None,
                None,
                Some(&("a".repeat(100))),
                None
            )
            .unwrap_err(),
            QueryError::QueryTooLong
        );
        assert!(matches!(
            q.build("abc", Some(-1), None, None, None),
            Err(QueryError::InvalidPageSize)
        ));
        assert!(matches!(
            q.build("abc", Some(-1), None, None, None),
            Err(QueryError::InvalidPageSize)
        ));
        assert!(matches!(
            q.build("abc", None, None, Some("f!"), None).unwrap_err(),
            QueryError::FilterError(FilterError::Parse { start, end })
            if start == 1 && end == 1
        ));
        assert_eq!(
            q.build("abc", None, None, Some(&("a".repeat(100))), None)
                .unwrap_err(),
            QueryError::FilterTooLong
        );
        assert_eq!(
            q.build("abc", None, None, Some("lol"), None).unwrap_err(),
            QueryError::FilterError(FilterError::UnknownMember("lol".into()))
        );
        assert_eq!(
            q.build("abc", None, None, None, Some(&("a".repeat(100))))
                .unwrap_err(),
            QueryError::OrderingTooLong
        );
        assert_eq!(
            q.build("abc", None, None, None, Some("lol")).unwrap_err(),
            QueryError::OrderingError(OrderingError::UnknownMember("lol".into()))
        );
    }

    fn get_query_builder() -> SearchQueryBuilder<PlainPageTokenBuilder> {
        SearchQueryBuilder::<PlainPageTokenBuilder>::new(
            UserItem::get_schema(),
            FunctionSchemaMap::new(),
            SearchQueryConfig {
                max_page_size: Some(20),
                default_page_size: 10,
                primary_ordering_term: Some(OrderingTerm {
                    name: "id".into(),
                    direction: OrderingDirection::Descending,
                }),
                max_query_length: Some(50),
                max_filter_length: Some(50),
                max_ordering_length: Some(50),
            },
            PlainPageTokenBuilder {},
        )
    }
}