Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion crates/lance-csr/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ 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<CypherQuery> {
let (remaining, query) = cypher_query(input).map_err(|e| GraphError::ParseError {
message: format!("Failed to parse Cypher query: {}", e),
position: 0,
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),
Expand Down Expand Up @@ -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";
Expand Down
4 changes: 4 additions & 0 deletions docs/cypher.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions python/python/tests/test_query_semicolon.py
Original file line number Diff line number Diff line change
@@ -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)
Loading