From 28096751241a8707042091e5012f99ce1f32f594 Mon Sep 17 00:00:00 2001 From: David Hughes Date: Mon, 26 Oct 2026 16:35:31 -0700 Subject: [PATCH] fix(parser): accept one trailing Cypher semicolon Refs lance-format/lance-graph#101. Adapt the focused fix to the current 0.3.1 engine and crate layout. Submitted by David Hughes (@stugorf), with AI Collaboration. --- crates/lance-csr/src/parser.rs | 33 +++++++++++- docs/cypher.md | 4 ++ python/python/tests/test_query_semicolon.py | 57 +++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 python/python/tests/test_query_semicolon.py diff --git a/crates/lance-csr/src/parser.rs b/crates/lance-csr/src/parser.rs index 716b351..18c7a48 100644 --- a/crates/lance-csr/src/parser.rs +++ b/crates/lance-csr/src/parser.rs @@ -19,7 +19,7 @@ use nom::{ }; use std::collections::HashMap; -/// Parse a complete Cypher query +/// Parse one complete Cypher query with an optional trailing semicolon. pub fn parse_cypher_query(input: &str) -> Result { let (remaining, query) = cypher_query(input).map_err(|e| GraphError::ParseError { message: format!("Failed to parse Cypher query: {}", e), @@ -27,6 +27,7 @@ pub fn parse_cypher_query(input: &str) -> Result { location: snafu::Location::new(file!(), line!(), column!()), })?; + let remaining = remaining.strip_prefix(';').unwrap_or(remaining); if !remaining.trim().is_empty() { return Err(GraphError::ParseError { message: format!("Unexpected input after query: {}", remaining), @@ -1616,6 +1617,36 @@ mod tests { use super::*; use crate::ast::{BooleanExpression, ComparisonOperator, PropertyValue, ValueExpression}; + #[test] + fn test_optional_trailing_semicolon() { + for query in [ + "MATCH (n:Person) RETURN n.name", + "MATCH (n:Person) RETURN n.id ORDER BY n.id SKIP 1 LIMIT 2", + "MATCH (n:Person) WITH n.name AS name RETURN name", + "MATCH (n:Person) RETURN 'a;b' AS text", + ] { + let expected = parse_cypher_query(query).unwrap(); + for suffix in [";", " ;", "; \n\t"] { + assert_eq!( + parse_cypher_query(&format!("{query}{suffix}")).unwrap(), + expected + ); + } + } + } + + #[test] + fn test_semicolon_does_not_allow_multiple_statements() { + for query in [ + ";", + "MATCH (n:Person) RETURN n.id;;", + "MATCH (n:Person) RETURN n.id; MATCH (m:Person) RETURN m.id", + "MATCH (n:Person) RETURN n.id; garbage", + ] { + assert!(parse_cypher_query(query).is_err(), "accepted {query}"); + } + } + #[test] fn test_parse_simple_node_query() { let query = "MATCH (n:Person) RETURN n.name"; diff --git a/docs/cypher.md b/docs/cypher.md index d0452fb..6db52d4 100644 --- a/docs/cypher.md +++ b/docs/cypher.md @@ -10,6 +10,10 @@ There are two ways to run Cypher, and most code wants the first: | CSR index | `use_csr=True` | Not available | | Streaming results | `execute_batches()` | No | +A query may end with one optional semicolon, including after `SKIP` or `LIMIT`. +Whitespace may follow it. Each execution call accepts one query; repeated semicolons +and additional statements are rejected. Semicolons inside string literals are ordinary text. + The examples on this page share this setup: ```python diff --git a/python/python/tests/test_query_semicolon.py b/python/python/tests/test_query_semicolon.py new file mode 100644 index 0000000..8aaad83 --- /dev/null +++ b/python/python/tests/test_query_semicolon.py @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""A final semicolon terminates one query without enabling multiple statements.""" + +import pyarrow as pa +import pytest +from lance_csr import CypherEngine, CypherQuery, GraphConfig + + +@pytest.mark.parametrize("mode", ["legacy", "join", "csr", "stream"]) +@pytest.mark.parametrize( + ("query", "expected"), + [ + ("RETURN 1 AS n;", [{"n": 1}]), + ("RETURN null AS n;", [{"n": None}]), + ("RETURN 1 AS n UNION ALL RETURN 1 AS n;", [{"n": 1}, {"n": 1}]), + ("MATCH (p:Person) RETURN p.id AS id ORDER BY id LIMIT 1;", [{"id": 1}]), + ( + "MATCH (p:Person) RETURN p.id AS id ORDER BY id SKIP 1 LIMIT 1 ; \n\t", + [{"id": 2}], + ), + ( + "MATCH (p:Person) WITH p.id AS id RETURN id ORDER BY id;", + [{"id": 1}, {"id": 2}], + ), + ("MATCH (p:Person) WHERE p.id = 1 RETURN 'a;b' AS text;", [{"text": "a;b"}]), + ("MATCH (p:Person) RETURN count(*) AS n;", [{"n": 2}]), + ], +) +def test_optional_query_semicolon(mode, query, expected): + config = GraphConfig.builder().node_table("Person", "id").build() + tables = {"Person": pa.table({"id": [1, 2]})} + if mode == "legacy": + result = CypherQuery(query).with_config(config).execute(tables) + else: + engine = CypherEngine(config, tables, use_csr=mode in ("csr", "stream")) + result = ( + engine.execute_batches(query).read_all() + if mode == "stream" + else engine.execute(query) + ) + assert result.to_pylist() == expected + + +@pytest.mark.parametrize( + "query", + [ + ";", + "MATCH (p:Person) RETURN p.id;;", + "MATCH (p:Person) RETURN p.id; MATCH (p:Person) RETURN p.id", + "MATCH (p:Person) RETURN p.id; garbage", + ], +) +def test_semicolon_rejects_additional_statements(query): + with pytest.raises(ValueError): + CypherQuery(query)