coil-data 0.1.1

Data access and persistence primitives for the Coil framework.
Documentation
use crate::{DataModelError, DataValue, FilterOperator, QueryField, QueryFilter};

pub(crate) fn ensure_repository_field(
    repository: &str,
    field: &QueryField,
    allowed: &[QueryField],
    locale_field: Option<&QueryField>,
    publication_field: Option<&QueryField>,
) -> Result<(), DataModelError> {
    if allowed.contains(field)
        || locale_field.is_some_and(|allowed_field| allowed_field == field)
        || publication_field.is_some_and(|allowed_field| allowed_field == field)
    {
        Ok(())
    } else {
        Err(DataModelError::UnknownRepositoryField {
            repository: repository.to_string(),
            field: field.to_string(),
        })
    }
}

pub(crate) fn compile_filters(
    filters: &[QueryFilter],
    start_index: usize,
) -> Result<(Vec<String>, Vec<DataValue>, usize), DataModelError> {
    let mut clauses = Vec::new();
    let mut bind_values = Vec::new();
    let mut index = start_index;

    for filter in filters {
        let (clause, values, next_index) = compile_filter(filter, index)?;
        clauses.push(clause);
        bind_values.extend(values);
        index = next_index;
    }

    Ok((clauses, bind_values, index))
}

fn compile_filter(
    filter: &QueryFilter,
    start_index: usize,
) -> Result<(String, Vec<DataValue>, usize), DataModelError> {
    let field = quote_identifier(filter.field.as_str());
    match filter.operator {
        FilterOperator::Eq => {
            ensure_filter_arity(filter, "exactly 1", 1..=1)?;
            Ok((
                format!("{field} = {}", render_placeholder(start_index)),
                vec![DataValue::String(filter.values[0].clone())],
                start_index + 1,
            ))
        }
        FilterOperator::Prefix => {
            ensure_filter_arity(filter, "exactly 1", 1..=1)?;
            Ok((
                format!("{field} LIKE {}", render_placeholder(start_index)),
                vec![DataValue::String(format!("{}%", filter.values[0]))],
                start_index + 1,
            ))
        }
        FilterOperator::Range => {
            ensure_filter_arity(filter, "exactly 2", 2..=2)?;
            Ok((
                format!(
                    "{field} BETWEEN {} AND {}",
                    render_placeholder(start_index),
                    render_placeholder(start_index + 1)
                ),
                vec![
                    DataValue::String(filter.values[0].clone()),
                    DataValue::String(filter.values[1].clone()),
                ],
                start_index + 2,
            ))
        }
        FilterOperator::In => {
            ensure_filter_arity(filter, "at least 1", 1..)?;
            let placeholders = (start_index..start_index + filter.values.len())
                .map(render_placeholder)
                .collect::<Vec<_>>()
                .join(", ");
            Ok((
                format!("{field} IN ({placeholders})"),
                filter
                    .values
                    .iter()
                    .cloned()
                    .map(DataValue::String)
                    .collect(),
                start_index + filter.values.len(),
            ))
        }
    }
}

pub(crate) fn ensure_filter_arity(
    filter: &QueryFilter,
    expected: &'static str,
    range: impl std::ops::RangeBounds<usize>,
) -> Result<(), DataModelError> {
    let actual = filter.values.len();
    let contains = match (range.start_bound(), range.end_bound()) {
        (std::ops::Bound::Included(start), std::ops::Bound::Included(end)) => {
            actual >= *start && actual <= *end
        }
        (std::ops::Bound::Included(start), std::ops::Bound::Unbounded) => actual >= *start,
        _ => false,
    };

    if contains {
        Ok(())
    } else {
        Err(DataModelError::InvalidFilterArity {
            operator: filter.operator,
            expected,
            actual,
        })
    }
}

pub(crate) fn quote_identifier(identifier: &str) -> String {
    identifier
        .split('.')
        .map(|part| format!("\"{}\"", part.replace('"', "\"\"")))
        .collect::<Vec<_>>()
        .join(".")
}

pub(crate) fn render_placeholder(index: usize) -> String {
    format!("${index}")
}