khive-query 0.10.0

GQL and SPARQL parsers with SQL compiler for knowledge graph queries.
Documentation
use khive_query::{
    compile, parse, parse_auto, validate, validate_with_warnings, CompileOptions, QueryError,
    QueryLanguage, ReturnItem,
};

fn assert_collision(error: QueryError) {
    assert!(
        matches!(error, QueryError::Validation(ref message)
            if message == "variable 'x' cannot bind both a node and an edge"),
        "{error:?}"
    );
}

#[test]
fn node_edge_name_reuse_is_rejected_by_validation_and_compilation() {
    let options = CompileOptions::default();
    for input in [
        "MATCH (x)-[x:extends]->(b) RETURN x.id",
        "MATCH (a)-[x:extends]->(x) RETURN x.id",
        "MATCH (x)-[x:extends*1..3]->(b) RETURN x.id",
        "MATCH (a)-[x:extends*1..3]->(x) RETURN x.id",
        "MATCH (x)-[e:extends]->(b)-[x:extends]->(c) RETURN x.id",
        "MATCH (a)-[x:extends]->(b)-[e:extends]->(x) RETURN x.id",
    ] {
        for parsed in [parse(QueryLanguage::Gql, input), parse_auto(input)] {
            let query = parsed.expect("binding validity is checked after syntax parsing");
            assert_collision(validate(&mut query.clone()).unwrap_err());
            assert_collision(validate_with_warnings(&mut query.clone()).unwrap_err());
            assert_collision(compile(&query, &options).unwrap_err());
        }
    }
}

#[test]
fn distinct_and_case_distinct_bindings_keep_their_projection_sources() {
    let options = CompileOptions::default();
    for (range, aliases) in [
        ("", ["n0.id", "e0.id", "n1.id"]),
        ("*1..3", ["s.id", "t.via_edge", "r.id"]),
    ] {
        for (node, edge) in [("a", "e"), ("x", "X")] {
            let input = format!(
                "MATCH ({node})-[{edge}:extends{range}]->(b) RETURN {node}.id, {edge}.id, b.id"
            );
            for parsed in [parse(QueryLanguage::Gql, &input), parse_auto(&input)] {
                let mut query = parsed.unwrap();
                validate(&mut query).unwrap();
                assert!(validate_with_warnings(&mut query).unwrap().is_empty());
                let compiled = compile(&query, &options).unwrap();
                assert_eq!(compiled.sql.contains("WITH RECURSIVE"), !range.is_empty());
                assert_eq!(
                    compiled.return_vars,
                    vec![
                        ReturnItem::Property(node.into(), "id".into()),
                        ReturnItem::Property(edge.into(), "id".into()),
                        ReturnItem::Property("b".into(), "id".into()),
                    ]
                );
                for (alias, variable) in aliases.into_iter().zip([node, edge, "b"]) {
                    let projection = format!("{alias} AS \"{variable}_id\"");
                    assert_eq!(
                        compiled.sql.matches(&projection).count(),
                        1,
                        "{}",
                        compiled.sql
                    );
                }
            }
        }
    }
}

#[test]
fn anonymous_bindings_do_not_collide() {
    for (range, start_alias, end_alias) in [("", "n0.id", "n1.id"), ("*1..3", "s.id", "r.id")] {
        for (pattern, variable, alias) in [
            (format!("(a)-[:extends{range}]->()"), "a", start_alias),
            (format!("()-[:extends{range}]->(b)"), "b", end_alias),
        ] {
            let input = format!("MATCH {pattern} RETURN {variable}.id");
            for parsed in [parse(QueryLanguage::Gql, &input), parse_auto(&input)] {
                let mut query = parsed.unwrap();
                validate(&mut query).unwrap();
                assert!(validate_with_warnings(&mut query).unwrap().is_empty());
                let compiled = compile(&query, &CompileOptions::default()).unwrap();
                assert_eq!(compiled.sql.contains("WITH RECURSIVE"), !range.is_empty());
                assert_eq!(
                    compiled.return_vars,
                    vec![ReturnItem::Property(variable.into(), "id".into())]
                );
                let projection = format!("{alias} AS \"{variable}_id\"");
                assert_eq!(
                    compiled.sql.matches(&projection).count(),
                    1,
                    "{}",
                    compiled.sql
                );
            }
        }
    }
}

#[test]
fn same_kind_reuse_keeps_existing_diagnostics() {
    for (input, message) in [
        (
            "MATCH (a)-[:extends]->(a) RETURN a.id",
            "repeated node variable 'a' (cycle / self-reachability requires alias-equality predicates not yet implemented)",
        ),
        (
            "MATCH (a)-[e:extends]->(b)-[e:extends]->(c) RETURN a.id",
            "repeated edge variable 'e' not supported",
        ),
    ] {
        for parsed in [parse(QueryLanguage::Gql, input), parse_auto(input)] {
            let query = parsed.unwrap();
            for error in [
                validate(&mut query.clone()).unwrap_err(),
                validate_with_warnings(&mut query.clone()).unwrap_err(),
                compile(&query, &CompileOptions::default()).unwrap_err(),
            ] {
                assert!(matches!(error, QueryError::Unsupported(ref text) if text == message), "{error:?}");
            }
        }
    }
}