khive-query 0.14.0

GQL and SPARQL parsers with SQL compiler for knowledge graph queries.
Documentation
#[test]
fn fixed_length_basic() {
    let q = gql::parse("MATCH (a:concept)-[e:introduced_by]->(b:paper) RETURN a, e, b LIMIT 10")
        .unwrap();
    let compiled = compile(&q, &opts()).unwrap();
    assert!(compiled.sql.contains("JOIN graph_edges"));
    assert!(compiled.sql.contains("LIMIT"));
    assert_eq!(
        compiled.return_vars,
        vec![
            ReturnItem::Variable("a".into()),
            ReturnItem::Variable("e".into()),
            ReturnItem::Variable("b".into()),
        ]
    );
    assert!(!compiled.sql.contains("WITH RECURSIVE"));
}

#[test]
fn namespace_scoping_injected() {
    let q = gql::parse("MATCH (a:concept)-[e:introduced_by]->(b:paper) RETURN a LIMIT 5").unwrap();
    let compiled = compile(&q, &scoped("research")).unwrap();
    assert!(compiled.sql.contains("namespace"));
    let has_ns_param = compiled
        .params
        .iter()
        .any(|p| matches!(p, QueryValue::Text(s) if s == "research"));
    assert!(has_ns_param, "namespace must be a bound parameter");
}

#[test]
fn edge_property_whitelist_rejects_unknown() {
    let q =
        gql::parse("MATCH (a)-[e:introduced_by]->(b) WHERE e.source_id = 'x' RETURN a").unwrap();
    let result = compile(&q, &opts());
    assert!(result.is_err());
    let err = result.unwrap_err().to_string();
    assert!(
        err.contains("source_id") || err.contains("not queryable"),
        "error: {err}"
    );
}

#[test]
fn edge_property_relation_allowed() {
    let q = gql::parse("MATCH (a)-[e]->(b) WHERE e.relation = 'extends' RETURN a").unwrap();
    let result = compile(&q, &opts());
    assert!(
        result.is_ok(),
        "relation should be allowed: {:?}",
        result.err()
    );
}

#[test]
fn edge_property_weight_allowed() {
    let q = gql::parse("MATCH (a)-[e]->(b) WHERE e.weight > 0.5 RETURN a").unwrap();
    let result = compile(&q, &opts());
    assert!(
        result.is_ok(),
        "weight should be allowed: {:?}",
        result.err()
    );
}

#[test]
fn variable_length_uses_cte() {
    let q = gql::parse("MATCH (a {name: 'LoRA'})-[:extends*1..3]->(b) RETURN b LIMIT 20").unwrap();
    let compiled = compile(&q, &opts()).unwrap();
    assert!(compiled.sql.contains("WITH RECURSIVE"));
    assert!(compiled.sql.contains("traverse"));
}

#[test]
fn depth_cap_at_ten_rejects_above_max() {
    let q = gql::parse("MATCH (a)-[:extends*1..50]->(b) RETURN b").unwrap();
    let err = compile(&q, &opts()).unwrap_err();
    assert!(
        matches!(err, QueryError::InvalidInput(_)),
        "expected InvalidInput for depth > 10, got {err:?}"
    );
}

#[test]
fn depth_within_cap_compiles() {
    let q = gql::parse("MATCH (a)-[:extends*1..10]->(b) RETURN b").unwrap();
    let compiled = compile(&q, &opts()).unwrap();
    assert!(compiled.sql.contains("WITH RECURSIVE"));
    let depth_val = compiled.params.iter().find_map(|p| {
        if let QueryValue::Integer(n) = p {
            Some(*n)
        } else {
            None
        }
    });
    assert_eq!(depth_val, Some(10), "depth param should be 10");
}

#[test]
fn limit_capped_by_max_limit() {
    let q = gql::parse("MATCH (a:concept)-[e]->(b) RETURN a LIMIT 1000").unwrap();
    let compiled = compile(&q, &opts()).unwrap();
    let limit_param = compiled.params.last().unwrap();
    assert!(
        matches!(limit_param, QueryValue::Integer(501)),
        "expected Integer(501), got {limit_param:?}"
    );
}

#[test]
fn limit_over_cap_requests_sentinel_row() {
    let q = gql::parse("MATCH (a:concept)-[e]->(b) RETURN a LIMIT 1000").unwrap();
    let compiled = compile(&q, &opts()).unwrap();
    assert_eq!(
        compiled.truncation_check,
        Some(TruncationCheck {
            max_limit: 500,
            requested_limit: Some(1000),
        })
    );
    let limit_param = compiled.params.last().unwrap();
    assert!(
        matches!(limit_param, QueryValue::Integer(501)),
        "expected sentinel LIMIT 501, got {limit_param:?}"
    );
}

#[test]
fn limit_exactly_at_cap_no_sentinel() {
    let q = gql::parse("MATCH (a:concept)-[e]->(b) RETURN a LIMIT 500").unwrap();
    let compiled = compile(&q, &opts()).unwrap();
    assert_eq!(compiled.truncation_check, None);
    let limit_param = compiled.params.last().unwrap();
    assert!(matches!(limit_param, QueryValue::Integer(500)));
}

#[test]
fn limit_below_cap_no_sentinel() {
    let q = gql::parse("MATCH (a:concept)-[e]->(b) RETURN a LIMIT 100").unwrap();
    let compiled = compile(&q, &opts()).unwrap();
    assert_eq!(compiled.truncation_check, None);
    let limit_param = compiled.params.last().unwrap();
    assert!(matches!(limit_param, QueryValue::Integer(100)));
}

#[test]
fn no_explicit_limit_requests_sentinel_row() {
    let q = gql::parse("MATCH (a:concept)-[e]->(b) RETURN a").unwrap();
    let compiled = compile(&q, &opts()).unwrap();
    assert_eq!(
        compiled.truncation_check,
        Some(TruncationCheck {
            max_limit: 500,
            requested_limit: None,
        })
    );
    let limit_param = compiled.params.last().unwrap();
    assert!(
        matches!(limit_param, QueryValue::Integer(501)),
        "expected sentinel LIMIT 501, got {limit_param:?}"
    );
}

#[test]
fn fixed_length_skip_is_bound_after_a_total_identity_order() {
    let q = gql::parse("MATCH (a:concept)-[e]->(b) RETURN a SKIP 500").unwrap();
    let compiled = compile(&q, &opts()).unwrap();
    assert!(
        compiled
            .sql
            .contains("ORDER BY n0.substrate_kind, n0.id, e0.id, n1.substrate_kind, n1.id"),
        "fixed-length pages need a deterministic total identity order: {}",
        compiled.sql
    );
    assert!(compiled.sql.contains("LIMIT ?") && compiled.sql.contains("OFFSET ?"));
    assert!(
        compiled
            .params
            .iter()
            .any(|p| matches!(p, QueryValue::Integer(500))),
        "SKIP must be a bound integer parameter: {:?}",
        compiled.params
    );
}

#[test]
fn variable_length_skip_totally_orders_distinct_projected_rows() {
    let q = gql::parse("MATCH (a)-[:extends*1..3]->(b) RETURN b SKIP 25").unwrap();
    let compiled = compile(&q, &opts()).unwrap();
    assert!(
            compiled.sql.contains(
                r#"ORDER BY "_depth", "_total_weight" DESC, "b_id", "b_namespace", "b_kind", "b_entity_type", "b_name""#
            ),
            "recursive DISTINCT pages need a total projected-row order: {}",
            compiled.sql
        );
    assert!(compiled.sql.contains("LIMIT ?") && compiled.sql.contains("OFFSET ?"));
    assert!(
        compiled
            .params
            .iter()
            .any(|p| matches!(p, QueryValue::Integer(25))),
        "SKIP must be a bound integer parameter: {:?}",
        compiled.params
    );
}

#[cfg(target_pointer_width = "64")]
#[test]
fn skip_over_sql_integer_range_is_rejected() {
    let too_large = (i64::MAX as u64) + 1;
    let q = gql::parse(&format!("MATCH (a:concept) RETURN a SKIP {too_large}")).unwrap();
    let err = compile(&q, &opts()).unwrap_err();
    assert!(
        matches!(err, QueryError::InvalidInput(_)) && err.to_string().contains("SKIP"),
        "unrepresentable SKIP must fail before SQL execution, got {err:?}"
    );
}

#[test]
fn variable_length_limit_over_cap_requests_sentinel_row() {
    let q = gql::parse("MATCH (a)-[:extends*1..3]->(b) RETURN b LIMIT 5000").unwrap();
    let compiled = compile(&q, &opts()).unwrap();
    assert_eq!(
        compiled.truncation_check,
        Some(TruncationCheck {
            max_limit: 500,
            requested_limit: Some(5000),
        })
    );
    let limit_param = compiled.params.last().unwrap();
    assert!(
        matches!(limit_param, QueryValue::Integer(501)),
        "expected sentinel LIMIT 501, got {limit_param:?}"
    );
}