haematite-cli 0.21.8

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
use super::super::{Span, Spanned, Stage};
use super::{Direction, Node, Relation, read};

type TestResult = Result<(), String>;

fn accepted(text: &str, start: usize) -> Result<Spanned<Stage>, String> {
    read(text, start).map_err(|error| error.to_string())
}

fn text(value: &str, start: usize, end: usize) -> Spanned<String> {
    Spanned {
        value: value.to_owned(),
        span: Span { start, end },
    }
}

fn patients() -> Node {
    Node {
        binding: text("c", 7, 8),
        label: text("patients", 9, 17),
        span: Span { start: 6, end: 18 },
    }
}

fn wards() -> Node {
    Node {
        binding: text("w", 36, 37),
        label: text("wards", 38, 43),
        span: Span { start: 35, end: 44 },
    }
}

#[test]
fn match_single_node_keeps_each_exact_span() -> TestResult {
    assert_eq!(
        accepted("match (c:patients)", 0)?,
        Spanned {
            value: Stage::Match {
                nodes: vec![patients()],
                relations: Vec::new(),
            },
            span: Span { start: 0, end: 18 },
        }
    );
    Ok(())
}

#[test]
fn match_both_arrow_directions_keep_each_exact_span() -> TestResult {
    for (query, direction, start, end) in [
        (
            "match (c:patients)-[:admitted_to]->(w:wards)",
            Direction::Outgoing,
            21,
            32,
        ),
        (
            "match (c:patients)<-[:admitted_to]-(w:wards)",
            Direction::Incoming,
            22,
            33,
        ),
    ] {
        assert_eq!(
            accepted(query, 0)?,
            Spanned {
                value: Stage::Match {
                    nodes: vec![patients(), wards()],
                    relations: vec![Relation {
                        kind: text("admitted_to", start, end),
                        direction,
                        span: Span { start: 18, end: 35 },
                    }],
                },
                span: Span { start: 0, end: 44 },
            },
            "{query}"
        );
    }
    Ok(())
}

#[test]
fn match_offsets_are_utf8_bytes_and_stop_before_the_outer_pipe() -> TestResult {
    let query =
        "\u{2003} match ( _c2 : studio.pages ) - [ : admitted_to ] -> ( w : wards ) | count";
    assert_eq!(
        accepted(query, 100)?,
        Spanned {
            value: Stage::Match {
                nodes: vec![
                    Node {
                        binding: text("_c2", 112, 115),
                        label: text("studio.pages", 118, 130),
                        span: Span {
                            start: 110,
                            end: 132
                        },
                    },
                    Node {
                        binding: text("w", 158, 159),
                        label: text("wards", 162, 167),
                        span: Span {
                            start: 156,
                            end: 169
                        },
                    },
                ],
                relations: vec![Relation {
                    kind: text("admitted_to", 139, 150),
                    direction: Direction::Outgoing,
                    span: Span {
                        start: 133,
                        end: 155
                    },
                }],
            },
            span: Span {
                start: 104,
                end: 169
            },
        }
    );
    Ok(())
}

#[test]
fn match_binding_names_have_the_declared_ascii_grammar() -> TestResult {
    for binding in ["c", "_", "_patient_2", "c0"] {
        let query = format!("match ({binding}:patients)");
        let parsed = accepted(&query, 0)?;
        let Stage::Match { nodes, relations } = parsed.value else {
            return Err("match returned another stage".into());
        };
        assert_eq!(nodes.len(), 1);
        assert_eq!(nodes[0].binding.value, binding);
        assert_eq!(nodes[0].binding.span.start, 7);
        assert_eq!(nodes[0].binding.span.end, 7 + binding.len());
        assert!(relations.is_empty());
    }
    for binding in ["C", "1c", "c-1", "c.name", "é", "cÉ", ""] {
        let query = format!("match ({binding}:patients)");
        let refused = read(&query, 0).err().ok_or("invalid binding accepted")?;
        assert_eq!(refused.code(), "query_stage_not_available", "{query}");
        assert!(refused.to_string().contains("binding name"), "{refused}");
    }
    Ok(())
}

#[test]
fn match_unsupported_constructs_keep_exact_refusal_spans() -> TestResult {
    for (query, construct, start, end) in [
        ("match (c)", "no label", 8, 9),
        ("match (c:patients {id: 1})", "property map", 18, 19),
        ("match (c:patients) WHERE c.id = 1", "WHERE", 19, 24),
        (
            "match (c:patients)-[r:in]->(w:wards)",
            "relation binding",
            20,
            21,
        ),
    ] {
        let refused = read(query, 50)
            .err()
            .ok_or("unsupported construct accepted")?;
        assert_eq!(refused.code(), "query_stage_not_available", "{query}");
        assert!(refused.to_string().contains(construct), "{refused}");
        assert_eq!(
            refused.span(),
            Span {
                start: 50 + start,
                end: 50 + end
            },
            "{query}"
        );
    }
    Ok(())
}

#[test]
fn match_malformed_patterns_never_become_another_language() -> TestResult {
    for (query, construct) in [
        ("match", "labelled node"),
        ("match(re)", "no label"),
        ("match (c:)", "no label"),
        ("match (c:patients", "unclosed node"),
        ("match (c:patients:other)", "multiple labels"),
        ("match (c:patients) (w:wards)", "without a relation"),
        ("match (c:patients), (w:wards)", "multiple patterns"),
        ("match (c:patients)-[]->(w:wards)", "no kind"),
        ("match (c:patients)-[:]->(w:wards)", "no kind"),
        ("match (c:patients)-[:in*]->(w:wards)", "variable-length"),
        ("match (c:patients)-[:in]-(w:wards)", "undirected"),
        ("match (c:patients)<-[:in]->(w:wards)", "bidirectional"),
        ("match (c:patients)-[:in]->", "node pattern"),
        ("match (c:patients) RETURN c", "suffix"),
        ("match (c:patients) {id: 1}", "property map"),
    ] {
        let refused = read(query, 0).err().ok_or("malformed match accepted")?;
        assert_eq!(refused.code(), "query_stage_not_available", "{query}");
        assert!(refused.to_string().contains(construct), "{refused}");
        assert!(
            query
                .get(refused.span().start..refused.span().end)
                .is_some()
        );
    }
    Ok(())
}