Files
boltage/bridge/peo4c/cypher.py
T
Bart BroereandClaude Opus 5.5 04e1c32223 License peo4c under GPL-3.0-or-later and ship third-party notices
- Add the GPL-3.0 LICENSE and SPDX headers to peo4c's own files.
- Add THIRD_PARTY_NOTICES.md listing every bundled component, its
  license and where its license text lives in the image, plus a
  trademark disclaimer; reference it from the README.
- Ship neo4j-browser's LICENSE, NOTICE.txt and LICENSES.txt with a
  source archive of the built commit, as the GPL requires.
- Ship Apache AGE's LICENSE and NOTICE, which the base image lacks.
- Collect license texts for Python packages whose wheels omit them,
  from PyPI sdists or vendored copies in docker/licenses/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:58:27 +02:00

239 lines
7.7 KiB
Python

# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
"""Light-weight Cypher text analysis needed to run queries through AGE.
AGE's ``cypher()`` SQL function needs the caller to declare the result columns
up front, does not accept Neo4j-style parameter maps reliably, and lacks a few
constructs neo4j-browser relies on. This module works on the query text only:
it masks out strings and comments, finds the final top-level RETURN clause,
and inlines parameters as Cypher literals.
"""
import math
import re
from dataclasses import dataclass
class CypherError(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code = code
self.message = message
@dataclass
class Scan:
text: str
masked: str # strings/backticks replaced by 'x', comments by spaces
depth: list # bracket nesting depth for every character
def scan(text: str) -> Scan:
masked = list(text)
depth = [0] * len(text)
level = 0
i, n = 0, len(text)
while i < n:
c = text[i]
if c in "'\"`":
j = i + 1
while j < n and text[j] != c:
j += 2 if text[j] == "\\" and c != "`" else 1
for k in range(i, min(j + 1, n)):
masked[k] = "x"
depth[k] = level
i = j + 1
continue
if text.startswith("//", i):
j = text.find("\n", i)
j = n if j == -1 else j
for k in range(i, j):
masked[k] = " "
depth[k] = level
i = j
continue
if text.startswith("/*", i):
j = text.find("*/", i + 2)
j = n if j == -1 else j + 2
for k in range(i, j):
masked[k] = " "
depth[k] = level
i = j
continue
if c in "([{":
depth[i] = level
level += 1
elif c in ")]}":
level = max(level - 1, 0)
depth[i] = level
else:
depth[i] = level
i += 1
return Scan(text, "".join(masked), depth)
def _top_level(s: Scan, pattern, start=0, end=None):
end = len(s.text) if end is None else end
for m in re.finditer(pattern, s.masked[start:end], re.IGNORECASE):
if s.depth[start + m.start()] == 0:
yield start + m.start(), start + m.end()
def strip_statement(text: str) -> str:
text = text.strip()
while text.endswith(";"):
text = text[:-1].rstrip()
return text
def return_columns(text: str) -> list:
"""Column names of the final top-level RETURN clause ([] if there is none)."""
s = scan(text)
returns = list(_top_level(s, r"(?<![\w.$])RETURN(?!\w)"))
if not returns:
return []
clause_start = returns[-1][1]
clause_end = len(text)
for start, _ in _top_level(s, r"(?<![\w.$])(ORDER\s+BY|SKIP|LIMIT|UNION)(?!\w)", clause_start):
clause_end = start
break
distinct = re.match(r"\s*DISTINCT(?!\w)", s.masked[clause_start:clause_end], re.IGNORECASE)
if distinct:
clause_start += distinct.end()
items, item_start = [], clause_start
for comma, _ in _top_level(s, ",", clause_start, clause_end):
items.append((item_start, comma))
item_start = comma + 1
items.append((item_start, clause_end))
columns = []
for start, end in items:
expr = text[start:end].strip()
if expr == "*":
raise CypherError("Neo.ClientError.Statement.FeatureNotSupported",
"RETURN * is not supported by peo4c; list the returned variables explicitly.")
alias = None
for a_start, a_end in _top_level(s, r"(?<![\w.$])AS\s+(`[^`]*`|\w+)\s*$", start, end):
alias = re.match(r"AS\s+(.*)", text[a_start:a_end].strip(), re.IGNORECASE | re.DOTALL).group(1)
if alias is not None:
name = alias.strip()
if name.startswith("`") and name.endswith("`"):
name = name[1:-1].replace("``", "`")
columns.append(name)
else:
columns.append(expr)
return columns
_PARAM = re.compile(r"\$(`(?:[^`]|``)+`|[A-Za-z_][A-Za-z0-9_]*|\d+)")
def inline_parameters(text: str, params: dict) -> str:
"""Replace ``$name`` references with literal Cypher values."""
if "$" not in text:
return text
s = scan(text)
pieces, last, missing = [], 0, []
for m in _PARAM.finditer(s.masked):
name = m.group(1)
if name.startswith("`"):
name = name[1:-1].replace("``", "`")
if name not in params:
missing.append(name)
continue
pieces.append(text[last:m.start()])
pieces.append(literal(params[name]))
last = m.end()
if missing:
raise CypherError("Neo.ClientError.Statement.ParameterMissing",
"Expected parameter(s): " + ", ".join(dict.fromkeys(missing)))
pieces.append(text[last:])
return "".join(pieces)
def _escape_string(v: str) -> str:
out = []
for ch in v:
if ch == "\\":
out.append("\\\\")
elif ch == "'":
out.append("\\'")
elif ch == "\n":
out.append("\\n")
elif ch == "\r":
out.append("\\r")
elif ch == "\t":
out.append("\\t")
elif ord(ch) < 0x20:
out.append(f"\\u{ord(ch):04x}")
else:
out.append(ch)
return "'" + "".join(out) + "'"
def literal(v) -> str:
if v is None:
return "null"
if v is True:
return "true"
if v is False:
return "false"
if isinstance(v, int):
return str(v)
if isinstance(v, float):
if math.isnan(v):
return "'NaN'::float"
if math.isinf(v):
return "'Infinity'::float" if v > 0 else "'-Infinity'::float"
return repr(v)
if isinstance(v, str):
return _escape_string(v)
if isinstance(v, (list, tuple)):
return "[" + ", ".join(literal(x) for x in v) + "]"
if isinstance(v, dict):
return "{" + ", ".join(
"`" + str(k).replace("`", "``") + "`: " + literal(x) for k, x in v.items()) + "}"
raise CypherError("Neo.ClientError.Statement.TypeError",
f"Parameters of type {type(v).__name__} are not supported by peo4c")
# neo4j-browser's "expand node" query uses a pattern comprehension, which AGE
# does not support. Rewrite it into an equivalent OPTIONAL MATCH + count.
_NEIGHBOUR_COUNT = re.compile(
r"WITH\s+(\w+)\s*,\s*size\(\s*\[\s*\(\s*\1\s*\)\s*--\s*\(\s*\)\s*\|\s*1\s*\]\s*\)\s+AS\s+(\w+)",
re.IGNORECASE)
def rewrite_for_age(text: str) -> str:
return _NEIGHBOUR_COUNT.sub(
lambda m: f"OPTIONAL MATCH ({m.group(1)})-[peo4c_r]-() "
f"WITH {m.group(1)}, count(peo4c_r) AS {m.group(2)}",
text)
# AGE fails with "not a common type" on `id(x) IN [a, b, ...]` when the list has
# more than one element, which neo4j-browser uses to fetch relationships between
# displayed nodes. Expand such lists of integer literals into an OR chain.
_ID_IN_LIST = re.compile(r"(?<![\w.])(id\(\s*\w+\s*\))\s+IN\s+\[([\s\d,+\-]*)\]", re.IGNORECASE)
def expand_id_in_lists(text: str) -> str:
s = scan(text)
pieces, last = [], 0
for m in _ID_IN_LIST.finditer(s.masked):
values = [v.strip() for v in m.group(2).split(",") if v.strip()]
if len(values) == 1:
continue
expr = text[m.start(1):m.end(1)]
pieces.append(text[last:m.start()])
pieces.append("(" + " OR ".join(f"{expr} = {v}" for v in values) + ")" if values else "false")
last = m.end()
pieces.append(text[last:])
return "".join(pieces)
def first_keyword(text: str) -> str:
m = re.match(r"\s*(\w+)", scan(text).masked)
return m.group(1).upper() if m else ""