sift-query-core 0.1.0

Backend-independent query model and strict parameter parser for SiftQuery
Documentation
mod support;
use sift_query_core::*;
use support::*;

#[test]
fn canonical_rpc_preserves_nested_predicates_projection_order_and_paging() {
    let policy = QueryPolicy::default();
    let parser = ParamParser::new(&policy).unwrap();
    for input in [
        "id=1&id=2&id-op=il&size=3&page=2",
        "a.id=1&b.id=2&c.name=hello%20%2B&gexpr=(a|b)%26c&orderBy=name:desc&onlySelect=id,name&offset=4&size=5",
        "id-op=nl",
        "",
    ] {
        let query = parser
            .parse::<TestEntity>(&RawParams::parse(input).unwrap())
            .unwrap();
        let raw = query.to_rpc(&policy).unwrap();
        assert_eq!(
            raw.to_query_string(),
            query.to_rpc(&policy).unwrap().to_query_string()
        );
        let roundtrip = parser.parse::<TestEntity>(&raw).unwrap();
        assert_eq!(query.order(), roundtrip.order());
        assert_eq!(query.paging(), roundtrip.paging());
        assert_eq!(query.projection(), roundtrip.projection());
        let renamed = ParamNames {
            page: "pageNo".to_owned(),
            size: "pageSize".to_owned(),
            group_expr: "groups".to_owned(),
            ..Default::default()
        };
        let remote = policy.clone().with_param_names(renamed.clone()).unwrap();
        let raw = query.to_rpc_with_names(&policy, &renamed).unwrap();
        assert_eq!(
            ParamParser::new(&remote)
                .unwrap()
                .parse::<TestEntity>(&raw)
                .unwrap()
                .paging(),
            query.paging()
        );
    }
}

#[test]
fn builder_rpc_and_cursor_use_the_same_validated_query_contract() {
    let policy = QueryPolicy::default().with_cursor_codec(CursorCodec::new([42; 32]).unwrap());
    let field =
        sift_query_core::FieldRef::<TestEntity, i64, sift_query_core::ScalarKind>::new(FieldId(0));
    let raw = QueryBuilder::new(&policy)
        .eq(field, 42)
        .page(2, 3)
        .build_for_rpc()
        .unwrap();
    let query = ParamParser::new(&policy)
        .unwrap()
        .parse::<TestEntity>(&raw)
        .unwrap();
    assert_eq!(query.paging().offset, 3);
    let first = ParamParser::new(&policy)
        .unwrap()
        .parse::<CursorEntity>(&RawParams::parse("cursor=").unwrap())
        .unwrap();
    let token = policy
        .cursor_codec()
        .unwrap()
        .encode::<CursorEntity>(
            first.order(),
            &[CursorValue::Scalar(sift_query_core::ScalarValue::I64(7))],
        )
        .unwrap();
    let next = ParamParser::new(&policy)
        .unwrap()
        .parse::<CursorEntity>(&RawParams::parse(&format!("cursor={token}")).unwrap())
        .unwrap();
    assert_eq!(
        next.to_rpc(&policy)
            .unwrap()
            .entries()
            .iter()
            .find(|(key, _)| key == "cursor")
            .unwrap()
            .1,
        token
    );
    assert!(next.into_user_query(&QueryPolicy::default()).is_err());
}

#[test]
fn rpc_preserves_compatibility_empty_results_and_enforces_transport_budgets() {
    let compatible = QueryPolicy::bean_searcher_4812();
    let query = ParamParser::new(&compatible)
        .unwrap()
        .parse::<TestEntity>(&RawParams::parse("size=999&page=2&id=4").unwrap())
        .unwrap();
    assert!(query.is_forced_empty());
    let encoded = query.to_rpc(&compatible).unwrap();
    let decoded = ParamParser::new(&compatible)
        .unwrap()
        .parse::<TestEntity>(&encoded)
        .unwrap();
    assert!(decoded.is_forced_empty());
    assert_eq!(decoded.paging(), query.paging());
    let policy = QueryPolicy::new(
        ParamNames::default(),
        QueryLimits {
            max_raw_items: 1,
            ..Default::default()
        },
    )
    .unwrap();
    let field = FieldRef::<TestEntity, i64, ScalarKind>::new(FieldId(0));
    let query = QueryBuilder::new(&policy).eq(field, 1).build().unwrap();
    assert!(query.to_rpc(&policy).is_err());
}

#[derive(Debug)]
struct Increment;
impl RawParamFilter for Increment {
    fn filter(&self, _: &'static EntityMeta, input: &RawParams) -> Result<RawParams, ParamError> {
        RawParams::from_entries(
            input.entries().iter().map(|(key, value)| {
                (
                    key.clone(),
                    if key.rsplit('.').next() == Some("id") {
                        (value.parse::<i64>().unwrap() + 1).to_string()
                    } else {
                        value.clone()
                    },
                )
            }),
            &QueryLimits::default(),
        )
    }
}

#[test]
fn rpc_rejects_filters_that_change_canonical_query_values() {
    let policy = QueryPolicy::default().with_raw_param_filter(Increment);
    let field = FieldRef::<TestEntity, i64, ScalarKind>::new(FieldId(0));
    let query = QueryBuilder::new(&policy).eq(field, 1).build().unwrap();
    assert!(query.to_rpc(&policy).is_err());
}

#[test]
fn rpc_parameter_snapshot_keeps_order_and_repeated_values() {
    let policy = QueryPolicy::default();
    let query = ParamParser::new(&policy)
        .unwrap()
        .parse::<TestEntity>(&RawParams::parse("id-op=il&id=1&id=2&size=3&page=2").unwrap())
        .unwrap();
    assert_eq!(
        query.to_rpc(&policy).unwrap().to_query_string(),
        "q0.id-op=il&q0.id=1&q0.id=2&gexpr=q0&size=3&page=2"
    );
}

#[test]
fn rpc_does_not_add_group_depth_to_an_already_valid_query() {
    let policy = QueryPolicy::new(
        ParamNames::default(),
        QueryLimits {
            max_group_depth: 2,
            ..Default::default()
        },
    )
    .unwrap();
    let parser = ParamParser::new(&policy).unwrap();
    let query = parser
        .parse::<TestEntity>(&RawParams::parse("a.id=1&b.id=2&c.id=3&gexpr=(a|b)%26c").unwrap())
        .unwrap();
    let raw = query.to_rpc(&policy).unwrap();
    assert_eq!(
        raw.entries()
            .iter()
            .find(|(key, _)| key == "gexpr")
            .unwrap()
            .1,
        "(q0|q1)&q3"
    );
    assert!(parser.parse::<TestEntity>(&raw).is_ok());
}

fn assert_rpc_values<E: SearchEntity>(policy: &QueryPolicy, input: &str) {
    let parser = ParamParser::new(policy).unwrap();
    let query = parser
        .parse::<E>(&RawParams::parse(input).unwrap())
        .unwrap();
    let decoded = parser.parse::<E>(&query.to_rpc(policy).unwrap()).unwrap();
    let conditions = |query: &UserQuery<E>| {
        query
            .predicate()
            .nodes()
            .iter()
            .filter_map(|node| match node {
                PredicateNode::Condition(condition) => Some(condition.clone()),
                _ => None,
            })
            .collect::<Vec<_>>()
    };
    assert_eq!(conditions(&query), conditions(&decoded));
}

#[test]
fn rpc_preserves_scalar_types_and_business_boolean_names() {
    let policy = QueryPolicy::default();
    assert_rpc_values::<BaseTyped>(
        &policy,
        "flag=true&count32=-42&count64=9007199254740993&ratio32=0.5&ratio64=1e-10&title=%E4%B8%AD%26%2B&blob=AQID",
    );
    let boolean = policy.clone().with_codec(BooleanCodec {
        name: "business",
        field: "flag",
        true_name: "enabled",
        false_name: "disabled",
    });
    assert_rpc_values::<BaseTyped>(&boolean, "flag=enabled");
    #[cfg(feature = "decimal")]
    assert_rpc_values::<DecimalTyped>(&policy, "amount=9007199254740993.123456789");
    #[cfg(feature = "uuid")]
    assert_rpc_values::<UuidTyped>(&policy, "external=550e8400-e29b-41d4-a716-446655440000");
}