Files
boltage/boltage/backend.py
T

536 lines
24 KiB
Python

# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
"""Executes Bolt queries against PostgreSQL + Apache AGE.
Every Neo4j "database" maps to an AGE graph. The special ``system`` database
only answers the administrative queries that neo4j-browser issues.
"""
import logging
import itertools
import re
import secrets
import ssl
import time
from dataclasses import dataclass, field
import psycopg
from psycopg import errors as pg_errors
from psycopg import sql
from . import agtype
from .cypher import (CypherError, expand_id_in_lists, first_keyword, has_keyword, inline_parameters,
return_columns, rewrite_for_age, strip_statement)
from .packstream import Node, Relationship
log = logging.getLogger("boltage.backend")
NEO4J_VERSION = "5.26.0"
SYSTEM_DB = "system"
SYSTEM_LABELS = ("_ag_label_vertex", "_ag_label_edge")
FETCH_SIZE = 1000 # rows fetched from a PostgreSQL cursor at a time
SYSTEM_UPDATE = {"system-updates": 1, "contains-system-updates": True}
# Rows the current transaction has written to a graph's label tables, as PostgreSQL's
# statistics count them. AGE reports no counts of its own and fires no triggers. Neo4j's
# counters, in the order of the query below. AGE rewrites a whole row for a change to its
# properties, so properties-set is the number of nodes and relationships changed.
WRITE_COUNTERS = ("nodes-created", "nodes-deleted", "relationships-created", "relationships-deleted",
"labels-added", "properties-set")
WRITE_COUNTS = """SELECT
coalesce(sum(pg_stat_get_xact_tuples_inserted(id)) FILTER (WHERE base = '_ag_label_vertex'), 0)::bigint,
coalesce(sum(pg_stat_get_xact_tuples_deleted(id)) FILTER (WHERE base = '_ag_label_vertex'), 0)::bigint,
coalesce(sum(pg_stat_get_xact_tuples_inserted(id)) FILTER (WHERE base = '_ag_label_edge'), 0)::bigint,
coalesce(sum(pg_stat_get_xact_tuples_deleted(id)) FILTER (WHERE base = '_ag_label_edge'), 0)::bigint,
coalesce(sum(pg_stat_get_xact_tuples_inserted(id))
FILTER (WHERE base = '_ag_label_vertex' AND name <> base), 0)::bigint,
coalesce(sum(pg_stat_get_xact_tuples_updated(id)), 0)::bigint
FROM (
-- Not from ag_label: a role that may write to a graph need not be able to read that.
SELECT c.oid AS id, c.relname AS name, coalesce(p.relname, c.relname) AS base
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN pg_catalog.pg_inherits i ON i.inhrelid = c.oid
LEFT JOIN pg_catalog.pg_class p ON p.oid = i.inhparent
WHERE n.nspname = {graph} AND c.relkind = 'r'
) labels"""
GRAPH_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.\-]{2,62}$")
# Procedures emulated by boltage, reported through SHOW PROCEDURES.
PROCEDURES = [
("db.labels", "List all node labels in the graph.", "db.labels() :: (label :: STRING)"),
("db.relationshipTypes", "List all relationship types in the graph.",
"db.relationshipTypes() :: (relationshipType :: STRING)"),
("db.propertyKeys", "List property keys in the graph.", "db.propertyKeys() :: (propertyKey :: STRING)"),
("db.schema.visualization", "Visualize the schema of the graph.",
"db.schema.visualization() :: (nodes :: LIST<NODE>, relationships :: LIST<RELATIONSHIP>)"),
("dbms.components", "List DBMS components and their versions.",
"dbms.components() :: (name :: STRING, versions :: LIST<STRING>, edition :: STRING)"),
("dbms.clientConfig", "Return config settings interesting to clients.",
"dbms.clientConfig() :: (name :: STRING, value :: STRING)"),
("dbms.showCurrentUser", "Show the current user.",
"dbms.showCurrentUser() :: (username :: STRING, roles :: LIST<STRING>, flags :: LIST<STRING>)"),
]
CLIENT_CONFIG = {
"browser.allow_outgoing_connections": "true",
"browser.credential_timeout": "0s",
"browser.retain_connection_credentials": "true",
"browser.retain_editor_history": "true",
"client.allow_telemetry": "false",
"dbms.security.auth_enabled": "true",
}
@dataclass
class Config:
pg_host: str
pg_port: int
pg_database: str
default_graph: str
advertised_bolt: str
tls: str = "disabled" # TLS on the Bolt port: disabled, optional or required
tls_context: ssl.SSLContext | None = None
class Cursor:
"""A PostgreSQL cursor. It runs in the Bolt transaction, or else in a transaction of its own."""
def __init__(self, session, name, own_transaction):
self.session, self.name, self.own_transaction = session, name, own_transaction
async def fetch(self):
return await self.session._sql(f"FETCH FORWARD {FETCH_SIZE} FROM {self.name}")
async def close(self):
await self.session._sql(f"CLOSE {self.name}" + ("; COMMIT" if self.own_transaction else ""))
@dataclass
class Result:
"""The rows of a query that have not been sent yet.
Rows come from ``rows``, and from ``cursor`` when that is set. At most one batch
of a cursor's rows is held here at a time.
"""
fields: list
rows: list = field(default_factory=list)
db: str = ""
query_type: str = "r"
t_first: int = 0
cursor: Cursor | None = None
position: int = 0 # the next row to send
stats: dict = field(default_factory=dict) # Neo4j's update counters, when there are any
@property
def buffered(self):
return self.position < len(self.rows)
@property
def done(self):
return not self.buffered and self.cursor is None
async def add_batch(self, batch):
"""Take a batch fetched from the cursor; a short batch means the cursor is finished."""
self.rows, self.position = [[agtype.parse(v) for v in row] for row in batch], 0
if len(batch) < FETCH_SIZE:
await self.close_cursor()
def take(self, limit: int) -> list:
"""The next ``limit`` buffered rows or fewer."""
rows = self.rows[self.position:self.position + limit]
self.position += len(rows)
return rows
async def refill(self):
"""Fetch more rows if none are buffered, so ``done`` tells whether any are left."""
if not self.buffered and self.cursor is not None:
await self.add_batch(await self.cursor.fetch())
async def close_cursor(self):
if self.cursor is not None:
cursor, self.cursor = self.cursor, None
await cursor.close()
async def close(self):
self.rows, self.position = [], 0
await self.close_cursor()
class BoltFailure(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code = code
self.message = message
def failure_from_pg(e: Exception) -> BoltFailure:
"""Map a PostgreSQL/AGE error onto a Neo4j status code."""
sqlstate = getattr(e, "sqlstate", None) or ""
diag = getattr(e, "diag", None)
message = ((diag.message_primary if diag else None) or str(e)).strip()
if sqlstate == "42601":
code = "Neo.ClientError.Statement.SyntaxError"
elif sqlstate.startswith("42"):
code = "Neo.ClientError.Statement.SemanticError"
elif sqlstate.startswith("22"):
code = "Neo.ClientError.Statement.ArgumentError"
elif sqlstate.startswith("23"):
code = "Neo.ClientError.Schema.ConstraintValidationFailed"
elif sqlstate.startswith("28"):
code = "Neo.ClientError.Security.Unauthorized"
elif sqlstate.startswith("40"):
code = "Neo.TransientError.Transaction.DeadlockDetected"
elif sqlstate == "57014":
code = "Neo.ClientError.Transaction.TransactionTimedOut"
elif sqlstate.startswith("08") or isinstance(e, psycopg.OperationalError):
code = "Neo.TransientError.General.DatabaseUnavailable"
else:
code = "Neo.DatabaseError.General.UnknownError"
return BoltFailure(code, message)
def write_stats(before, after) -> dict:
"""Neo4j's update counters from two rows of ``WRITE_COUNTS``."""
stats = {name: a - b for name, a, b in zip(WRITE_COUNTERS, after, before) if a != b}
if stats:
stats["contains-updates"] = True
return stats
def _norm(query: str) -> str:
return re.sub(r"\s+", " ", strip_statement(query)).strip()
class Session:
"""One PostgreSQL connection, owned by one Bolt connection."""
def __init__(self, config: Config, user: str):
self.config = config
self.user = user
self.conn: psycopg.AsyncConnection | None = None
self.tx_db: str | None = None
self.cursor_ids = itertools.count(1)
async def connect(self, password: str):
try:
self.conn = await psycopg.AsyncConnection.connect(
host=self.config.pg_host, port=self.config.pg_port, dbname=self.config.pg_database,
user=self.user, password=password, autocommit=True,
application_name="boltage")
except psycopg.OperationalError as e:
msg = str(e)
if "password authentication failed" in msg or "role" in msg and "does not exist" in msg:
raise BoltFailure("Neo.ClientError.Security.Unauthorized",
"The client is unauthorized due to authentication failure.") from e
raise BoltFailure("Neo.TransientError.General.DatabaseUnavailable",
f"Could not connect to PostgreSQL: {msg.strip()}") from e
try:
await self.conn.execute("LOAD 'age'")
except psycopg.Error:
pass # Already preloaded, or not permitted for this role.
await self.conn.execute('SET search_path = ag_catalog, "$user", public')
async def close(self):
if self.conn is not None:
await self.conn.close()
self.conn = None
# -- transactions ---------------------------------------------------------
@property
def in_transaction(self):
return self.tx_db is not None
async def begin(self, db):
db = await self.resolve_db(db)
await self._sql("BEGIN")
self.tx_db = db
async def commit(self):
self.tx_db = None
await self._sql("COMMIT")
async def rollback(self):
self.tx_db = None
if self.conn is not None and not self.conn.closed:
try:
await self._sql("ROLLBACK")
except BoltFailure:
pass
async def reset(self):
await self.rollback()
# -- query execution ------------------------------------------------------
async def _sql(self, query, params=None):
try:
cur = await self.conn.execute(query, params)
except psycopg.Error as e:
raise failure_from_pg(e) from e
return await cur.fetchall() if cur.description else []
async def _script(self, script, own_transaction) -> list:
"""Run statements in one round trip; return the rows of those that return rows.
``own_transaction`` says that the script starts a transaction, to end if it fails.
"""
try:
cur = await self.conn.execute(script)
results = []
while True:
if cur.description:
results.append(await cur.fetchall())
if not cur.nextset():
return results
except psycopg.Error as e:
if own_transaction:
await self.rollback()
raise failure_from_pg(e) from e
async def graphs(self):
rows = await self._sql("SELECT name FROM ag_catalog.ag_graph ORDER BY name")
return [r[0] for r in rows]
async def resolve_db(self, db):
if not db:
db = self.config.default_graph
if db.lower() == SYSTEM_DB:
return SYSTEM_DB
for name in await self.graphs():
if name == db or name.lower() == db.lower():
return name
raise BoltFailure("Neo.ClientError.Database.DatabaseNotFound",
f"Database does not exist. Database name: '{db}'.")
async def run(self, query: str, params: dict, db: str | None) -> Result:
started = time.monotonic()
db = self.tx_db if self.in_transaction else await self.resolve_db(db)
try:
result = await self._run_system(query, db)
if result is None:
if db == SYSTEM_DB:
raise BoltFailure("Neo.ClientError.Statement.NotSystemDatabaseCommand",
"This is an administration command and it should be executed "
"against the system database: boltage only supports a few of them.")
result = await self._run_cypher(query, params or {}, db)
except CypherError as e:
raise BoltFailure(e.code, e.message) from e
result.db = db
result.t_first = int((time.monotonic() - started) * 1000)
return result
async def _run_cypher(self, query, params, graph) -> Result:
text = strip_statement(query)
keyword = first_keyword(text)
if keyword in ("EXPLAIN", "PROFILE"):
raise CypherError("Neo.ClientError.Statement.FeatureNotSupported",
f"{keyword} is not supported by Apache AGE.")
text = expand_id_in_lists(inline_parameters(rewrite_for_age(text), params))
columns = return_columns(text)
tag = "boltage"
while f"${tag}$" in text:
tag = "boltage_" + secrets.token_hex(4)
col_defs = ", ".join(f"c{i} ag_catalog.agtype" for i in range(max(len(columns), 1)))
stmt = (f"SELECT * FROM ag_catalog.cypher({sql.Literal(graph).as_string(self.conn)}, "
f"${tag}$ {text} ${tag}$) AS ({col_defs})")
log.debug("SQL: %s", stmt)
writes = has_keyword(text, "CREATE|MERGE|SET|DELETE|REMOVE")
# Only reads go through a cursor. AGE performs writes as their rows are fetched, so a
# cursor would delay them past the next query, and leave them half done if a client
# stops reading. A procedure called with CALL may write too.
if columns and not writes and not has_keyword(text, "CALL"):
return await self._stream(stmt, columns)
# The counters only stay comparable within a transaction: outside of one, PostgreSQL
# can reset them between the two readings.
own_transaction = not self.in_transaction
counts = WRITE_COUNTS.format(graph=sql.Literal(graph).as_string(self.conn))
script = f"{counts}; {stmt}; {counts}"
if own_transaction:
script = f"BEGIN; {script}; COMMIT"
before, rows, after = await self._script(script, own_transaction)
stats = write_stats(before[0], after[0])
rows = [[agtype.parse(v) for v in row] for row in rows] if columns else []
query_type = ("rw" if columns else "w") if writes or stats else "r"
return Result(columns, rows, query_type=query_type, stats=stats)
async def _stream(self, stmt, columns) -> Result:
"""Open a cursor on a read query and fetch the first batch, which reports most errors."""
name = f"boltage_c{next(self.cursor_ids)}"
own_transaction = not self.in_transaction
# PostgreSQL plans cursors for fetching only a part of the rows by default; clients
# usually read everything, so plan like for a plain query.
script = (f"{'BEGIN; ' if own_transaction else ''}SET LOCAL cursor_tuple_fraction = 1; "
f"DECLARE {name} NO SCROLL CURSOR FOR {stmt}; FETCH FORWARD {FETCH_SIZE} FROM {name}")
batch = (await self._script(script, own_transaction))[-1]
result = Result(columns, cursor=Cursor(self, name, own_transaction))
await result.add_batch(batch)
return result
# -- emulated Neo4j procedures ---------------------------------------------
async def _run_system(self, query, db) -> Result | None:
q = _norm(query)
ql = q.lower()
if ql.startswith("call dbms.components()"):
return Result(["name", "versions", "edition"], [["Neo4j Kernel", [NEO4J_VERSION], "community"]])
if ql.startswith("call dbms.clientconfig()") or ql.startswith("call dbms.listconfig()"):
return Result(["name", "value"], [[k, v] for k, v in CLIENT_CONFIG.items()])
if ql.startswith("call dbms.showcurrentuser()") or ql == "show current user":
return Result(["username", "roles", "flags"], [[self.user, ["admin"], []]])
if ql.startswith("show procedures"):
return Result(["name", "description", "signature"], [list(p) for p in PROCEDURES])
if ql.startswith("show functions"):
return Result(["name", "description", "signature"], [])
if re.match(r"^show (range |lookup |fulltext |text |point |vector |all )?indexes", ql) \
or ql.startswith("call db.indexes()"):
return Result(["id", "name", "state", "populationPercent", "type", "entityType",
"labelsOrTypes", "properties", "indexProvider", "owningConstraint"], [])
if re.match(r"^show (all |unique |exist(ence)? |node key )?constraints", ql) \
or ql.startswith("call db.constraints()"):
return Result(["id", "name", "type", "entityType", "labelsOrTypes", "properties",
"ownedIndex", "propertyType"], [])
m = re.match(r"^show (?:(home|default) database|databases|database (`[^`]+`|[\w.\-]+))(?!\w)",
q, re.IGNORECASE)
if m:
return await self._show_databases(m.group(1), m.group(2))
m = re.match(r"^create (?:database|graph) (`[^`]+`|[\w.\-]+)( if not exists)?$", q, re.IGNORECASE)
if m:
return await self._create_graph(m.group(1).strip("`"), bool(m.group(2)))
m = re.match(r"^drop (?:database|graph) (`[^`]+`|[\w.\-]+)( if exists)?", q, re.IGNORECASE)
if m:
return await self._drop_graph(m.group(1).strip("`"), bool(m.group(2)))
if db == SYSTEM_DB:
return None
# neo4j-browser's combined metadata queries (see dbMetaDuck.ts upstream).
if "call db.labels() yield label" in ql and "db.relationshiptypes()" in ql and "union all" in ql:
labels, types = await self._labels(db)
return Result(["result"], [[{"name": "labels", "data": labels}],
[{"name": "relationshipTypes", "data": types}],
[{"name": "propertyKeys", "data": await self._property_keys(db)}]])
if "return { name:'nodes', data:count(*) } as result" in ql:
nodes, rels = await self._counts(db)
return Result(["result"], [[{"name": "nodes", "data": nodes}],
[{"name": "relationships", "data": rels}]])
if re.match(r"^call db\.labels\(\)", ql):
labels, _ = await self._labels(db)
return Result(["label"], [[x] for x in labels])
if re.match(r"^call db\.relationshiptypes\(\)", ql):
_, types = await self._labels(db)
return Result(["relationshipType"], [[x] for x in types])
if re.match(r"^call db\.propertykeys\(\)", ql):
return Result(["propertyKey"], [[x] for x in await self._property_keys(db)])
if re.match(r"^call db\.schema\.visualization\(\)", ql):
return await self._schema_visualization(db)
m = re.match(r"^call (db|dbms|apoc|gds)\.[\w.]+", ql)
if m:
raise BoltFailure("Neo.ClientError.Procedure.ProcedureNotFound",
f"There is no procedure with the name `{m.group(0)[5:]}` registered "
"for this database instance (boltage).")
return None
async def _show_databases(self, special, name):
graphs = await self.graphs()
default = self.config.default_graph
if default not in graphs and graphs:
default = graphs[0]
rows = []
for g in [SYSTEM_DB] + graphs:
is_default = g == default
rows.append([g, "system" if g == SYSTEM_DB else "standard", [], "read-write",
self.config.advertised_bolt, "primary", True, "online", "online", "",
is_default, is_default, []])
if special:
rows = [r for r in rows if r[10]]
elif name:
name = name.strip("`").lower()
rows = [r for r in rows if r[0].lower() == name]
return Result(["name", "type", "aliases", "access", "address", "role", "writer",
"requestedStatus", "currentStatus", "statusMessage", "default", "home",
"constituents"], rows)
async def _create_graph(self, name, if_not_exists):
if not GRAPH_NAME.match(name):
raise BoltFailure("Neo.ClientError.Statement.ArgumentError",
f"Invalid graph name '{name}': AGE graph names must be 3-63 characters "
"of letters, digits, '_', '.' or '-', starting with a letter or '_'.")
if name in await self.graphs():
if if_not_exists:
return Result([], query_type="s")
raise BoltFailure("Neo.ClientError.Database.ExistingDatabaseFound",
f"Failed to create the specified database '{name}': Database already exists.")
await self._sql("SELECT ag_catalog.create_graph(%s)", [name])
return Result([], query_type="s", stats=SYSTEM_UPDATE)
async def _drop_graph(self, name, if_exists):
graphs = {g.lower(): g for g in await self.graphs()}
if name.lower() not in graphs:
if if_exists:
return Result([], query_type="s")
raise BoltFailure("Neo.ClientError.Database.DatabaseNotFound",
f"Failed to delete the specified database '{name}': Database does not exist.")
await self._sql("SELECT ag_catalog.drop_graph(%s, true)", [graphs[name.lower()]])
return Result([], query_type="s", stats=SYSTEM_UPDATE)
async def _labels(self, graph):
rows = await self._sql(
"SELECT l.name, l.kind FROM ag_catalog.ag_label l "
"JOIN ag_catalog.ag_graph g ON l.graph = g.graphid "
"WHERE g.name = %s AND l.name <> ALL(%s) ORDER BY l.name",
[graph, list(SYSTEM_LABELS)])
return [r[0] for r in rows if r[1] == "v"], [r[0] for r in rows if r[1] == "e"]
async def _cypher_values(self, graph, query):
rows = await self._sql(
f"SELECT * FROM ag_catalog.cypher({sql.Literal(graph).as_string(self.conn)}, "
f"$boltage$ {query} $boltage$) AS (c0 ag_catalog.agtype)")
return [agtype.parse(r[0]) for r in rows]
async def _property_keys(self, graph):
# Sample rather than scan everything: this runs every 20s from the browser.
keys = set()
for q in ("MATCH (n) WITH n LIMIT 1000 UNWIND keys(n) AS k RETURN DISTINCT k",
"MATCH ()-[r]->() WITH r LIMIT 1000 UNWIND keys(r) AS k RETURN DISTINCT k"):
keys.update(await self._cypher_values(graph, q))
return sorted(k for k in keys if isinstance(k, str))
async def _counts(self, graph):
nodes = await self._sql(sql.SQL("SELECT count(*) FROM {}._ag_label_vertex").format(sql.Identifier(graph)))
rels = await self._sql(sql.SQL("SELECT count(*) FROM {}._ag_label_edge").format(sql.Identifier(graph)))
return nodes[0][0], rels[0][0]
async def _schema_visualization(self, graph):
labels, _ = await self._labels(graph)
triples = await self._cypher_values(
graph, "MATCH (a)-[r]->(b) RETURN DISTINCT [label(a), label(r), label(b)]")
node_ids = {}
nodes = []
for label in labels + [t[0] for t in triples] + [t[2] for t in triples]:
if label not in node_ids:
node_ids[label] = -(len(node_ids) + 1)
nodes.append(Node(node_ids[label], [label or "(no label)"],
{"name": label or "(no label)", "indexes": [], "constraints": []}))
rels = [Relationship(-(i + 1), node_ids[a], node_ids[b], t, {"name": t})
for i, (a, t, b) in enumerate(triples)]
return Result(["nodes", "relationships"], [[nodes, rels]])