sift-query-core 0.1.0

Backend-independent query model and strict parameter parser for SiftQuery
Documentation
use std::collections::BTreeSet;

use crate::{
    CursorValue, NullOrder, OrderBy, Paging, PagingMode, ParamError, Projection, QueryPolicy,
    SearchEntity, SortOrder,
};

pub(super) fn parse_projection<E: SearchEntity>(
    only_select: Option<&str>,
    select_exclude: Option<&str>,
) -> Result<Projection, ParamError> {
    if only_select.is_some() && select_exclude.is_some() {
        return Err(ParamError::ConflictingParameters(
            "onlySelect and selectExclude".to_owned(),
        ));
    }
    let Some((value, excluding)) = only_select
        .map(|value| (value, false))
        .or_else(|| select_exclude.map(|value| (value, true)))
    else {
        return Ok(Projection::All);
    };
    let names = parse_csv(value, "projection")?;
    let mut ids = BTreeSet::new();
    for name in names {
        let field = E::META
            .resolve_field(name)
            .ok_or_else(|| ParamError::UnknownField(name.to_owned()))?;
        if !field.selectable || field.result_alias.is_none() {
            return Err(ParamError::NotSelectable(name.to_owned()));
        }
        if !ids.insert(field.id) {
            return Err(ParamError::DuplicateParameter(name.to_owned()));
        }
    }
    let selected = E::META
        .fields
        .iter()
        .filter(|field| {
            field.selectable
                && field.result_alias.is_some()
                && (ids.contains(&field.id) != excluding)
        })
        .map(|field| field.id)
        .collect::<Vec<_>>();
    if selected.is_empty() {
        return Err(ParamError::EmptyProjection);
    }
    Ok(Projection::Fields(selected))
}

pub(super) fn parse_order<E: SearchEntity>(
    policy: &QueryPolicy,
    sort: Option<&str>,
    order: Option<&str>,
    order_by: Option<&str>,
) -> Result<Vec<OrderBy>, ParamError> {
    if order_by.is_some() && (sort.is_some() || order.is_some()) {
        return Err(ParamError::ConflictingParameters(
            "sort/order and orderBy".to_owned(),
        ));
    }
    if order.is_some() && sort.is_none() {
        return Err(ParamError::ConflictingParameters(
            "order requires sort".to_owned(),
        ));
    }

    let mut parsed = Vec::new();
    if let Some(value) = order_by {
        for item in parse_csv(value, "orderBy")? {
            let (field, direction) =
                item.split_once(':')
                    .ok_or_else(|| ParamError::InvalidValue {
                        field: "orderBy".to_owned(),
                        reason: format!("missing direction for {item}"),
                    })?;
            parsed.push(resolve_order::<E>(field, direction)?);
        }
    } else if let Some(value) = sort {
        let fields = parse_csv(value, "sort")?;
        let directions = match order {
            Some(value) => parse_csv(value, "order")?,
            None => vec!["asc"; fields.len()],
        };
        if fields.len() != directions.len() {
            return Err(ParamError::ConflictingParameters(
                "sort and order lengths differ".to_owned(),
            ));
        }
        for (field, direction) in fields.into_iter().zip(directions) {
            parsed.push(resolve_order::<E>(field, direction)?);
        }
    }
    if parsed.len() > policy.limits().max_sort_fields {
        return Err(ParamError::TooComplex("sort field count"));
    }
    ensure_unique_order::<E>(&parsed)?;

    if parsed.is_empty() {
        parsed.extend(E::META.default_order.iter().copied().map(OrderBy::from));
    }
    if E::META.stable_order.is_empty() {
        return Err(ParamError::UnstablePagination);
    }
    for stable in E::META.stable_order {
        if !parsed.iter().any(|item| item.field == stable.field) {
            parsed.push((*stable).into());
        }
    }
    Ok(parsed)
}

fn resolve_order<E: SearchEntity>(field: &str, direction: &str) -> Result<OrderBy, ParamError> {
    let meta = E::META
        .resolve_field(field)
        .ok_or_else(|| ParamError::UnknownField(field.to_owned()))?;
    if !meta.sortable {
        return Err(ParamError::NotSortable(field.to_owned()));
    }
    let direction = SortOrder::parse(direction).ok_or_else(|| ParamError::InvalidValue {
        field: "order".to_owned(),
        reason: format!("invalid direction {direction}"),
    })?;
    Ok(OrderBy {
        field: meta.id,
        direction,
        nulls: NullOrder::from(direction),
    })
}

fn ensure_unique_order<E: SearchEntity>(order: &[OrderBy]) -> Result<(), ParamError> {
    let mut seen = BTreeSet::new();
    for item in order {
        if !seen.insert(item.field) {
            let name = E::META
                .field(item.field)
                .map_or("<unknown>", |field| field.public_name);
            return Err(ParamError::DuplicateParameter(format!("sort {name}")));
        }
    }
    Ok(())
}

pub(super) fn parse_paging<E: SearchEntity>(
    policy: &QueryPolicy,
    page: Option<&str>,
    size: Option<&str>,
    offset: Option<&str>,
    cursor: Option<&str>,
    order: &[OrderBy],
) -> Result<Paging, ParamError> {
    if page.is_some() && offset.is_some()
        || cursor.is_some() && (page.is_some() || offset.is_some())
    {
        return Err(ParamError::ConflictingParameters(
            "page, offset, and cursor are mutually exclusive".to_owned(),
        ));
    }
    let size_value = parse_u64(size.unwrap_or("15"), "size")?;
    let max_size = u64::from(
        policy
            .limits()
            .max_page_size
            .min(E::META.limits.max_page_size),
    );
    if size_value > max_size {
        return Err(ParamError::PagingOutOfRange("size".to_owned()));
    }
    let size_value =
        u32::try_from(size_value).map_err(|_| ParamError::PagingOutOfRange("size".to_owned()))?;
    let max_offset = policy.limits().max_offset.min(E::META.limits.max_offset);
    if let Some(cursor) = cursor {
        if order.iter().any(|order| {
            E::META
                .field(order.field)
                .is_none_or(|field| !field.value_type.supports_cursor())
        }) {
            return Err(ParamError::InvalidValue {
                field: policy.names().cursor.clone(),
                reason: "ordering type is not eligible for cursor pagination".to_owned(),
            });
        }
        if cursor.len() > policy.limits().max_cursor_bytes {
            return Err(ParamError::TooComplex("cursor length"));
        }
        if E::META.is_grouped() {
            return Err(ParamError::InvalidValue {
                field: policy.names().cursor.clone(),
                reason: "cursor pagination does not support grouped entities in M2".to_owned(),
            });
        }
        let codec = policy
            .cursor_codec()
            .ok_or_else(|| ParamError::InvalidValue {
                field: policy.names().cursor.clone(),
                reason: "cursor pagination is not configured".to_owned(),
            })?;
        let cursor = if cursor.is_empty() {
            None
        } else {
            let cursor = codec.decode::<E>(cursor, order)?;
            for (item, value) in order.iter().zip(cursor.values()) {
                let field = E::META
                    .field(item.field)
                    .ok_or_else(|| ParamError::UnknownField(format!("#{}", item.field.0)))?;
                if let CursorValue::Scalar(value) = value
                    && value.value_type() != field.value_type
                {
                    return Err(ParamError::InvalidValue {
                        field: policy.names().cursor.clone(),
                        reason: format!(
                            "cursor value type does not match ordering field {}",
                            field.public_name
                        ),
                    });
                }
            }
            Some(cursor)
        };
        Ok(Paging {
            mode: PagingMode::Cursor(cursor),
            size: size_value,
            offset: 0,
            explicit_page: false,
            explicit_size: size.is_some(),
            explicit_offset: false,
            explicit_cursor: true,
        })
    } else if let Some(offset) = offset {
        let offset_value = parse_u64(offset, "offset")?;
        if offset_value > max_offset {
            return Err(ParamError::PagingOutOfRange("offset".to_owned()));
        }
        Ok(Paging {
            mode: PagingMode::Offset,
            size: size_value,
            offset: offset_value,
            explicit_page: false,
            explicit_size: size.is_some(),
            explicit_offset: true,
            explicit_cursor: false,
        })
    } else {
        let page_value = parse_u64(page.unwrap_or("1"), "page")?;
        if page_value == 0 {
            return Err(ParamError::PagingOutOfRange("page".to_owned()));
        }
        let offset_value = (page_value - 1)
            .checked_mul(u64::from(size_value))
            .ok_or_else(|| ParamError::PagingOutOfRange("page offset overflow".to_owned()))?;
        if offset_value > max_offset {
            return Err(ParamError::PagingOutOfRange(
                "computed page offset".to_owned(),
            ));
        }
        Ok(Paging {
            mode: PagingMode::Page { page: page_value },
            size: size_value,
            offset: offset_value,
            explicit_page: page.is_some(),
            explicit_size: size.is_some(),
            explicit_offset: false,
            explicit_cursor: false,
        })
    }
}

fn parse_u64(value: &str, field: &str) -> Result<u64, ParamError> {
    value.parse().map_err(|_| ParamError::InvalidValue {
        field: field.to_owned(),
        reason: "expected a non-negative integer".to_owned(),
    })
}

fn parse_csv<'value>(value: &'value str, field: &str) -> Result<Vec<&'value str>, ParamError> {
    if value.is_empty() || value.bytes().any(|byte| byte.is_ascii_whitespace()) {
        return Err(ParamError::InvalidValue {
            field: field.to_owned(),
            reason: "list must be non-empty and contain no whitespace".to_owned(),
        });
    }
    let items = value.split(',').collect::<Vec<_>>();
    if items.iter().any(|item| item.is_empty()) {
        return Err(ParamError::InvalidValue {
            field: field.to_owned(),
            reason: "list contains an empty item".to_owned(),
        });
    }
    Ok(items)
}