import { GraphStore } from '../graph-store.js'; import '../graph-schema.js'; import '@remnic/core/runtime/better-sqlite'; import '@remnic/core/coding/coding-graph-types'; /** * openCypher read subset — hand-written recursive-descent parser + executor. * * Issue #1552 PR3. This module is the thin, deletable Cypher layer over the * structured store API (`searchGraph` / `traverse`). It compiles a strict * read-only subset of openCypher to those primitives — there is NO SQL * string assembly from user input anywhere; the structured API already * parameterizes every bind (rule 51). * * ## Supported grammar (strict subset) * * ``` * query := MATCH pattern [WHERE where_clause] RETURN return_list [LIMIT int] * pattern := node_pattern (rel_pattern node_pattern)* * node_pattern := '(' [var] [':' label] ['{' prop_map '}'] ')' * prop_map := key ':' literal (',' key ':' literal)* * rel_pattern := ('<-'? '--' bracket '--' '->'?) * | ('<-' bracket '--') // incoming: <-[...]- (also <-[...]--) * | ('--' bracket '->') // outgoing: -[...]-> (also --[...]->) * | ('<-'? '--' bracket '--' '->'?) // canonical form * bracket := '[' [':' type ('|' ':' type)*] ['*' range] ']' * range := int ('..' int)? | '..' int * where_clause := comparison ((AND | OR) comparison)* * comparison := var '.' key op literal * op := '=' | '<>' | '!=' | '>' | '<' | '>=' | '<=' * return_list := return_item (',' return_item)* * return_item := var | var '.' key * literal := string | number | 'true' | 'false' | 'null' * ``` * * Direction (resolved from the dashes/arrows around the bracket): * - `-[...]->` → outgoing (follow src→dst edges) * - `<-[...]-` → incoming (follow dst→src edges) * - `-[...]-` → both * * Variable-length hops: * - `-[:CALLS*1..3]->` → between 1 and 3 CALLS hops (inclusive) * - `-[:CALLS*2]->` → exactly 2 hops * - `-[:CALLS..3]->` → 1..3 hops (default min = 1) * - `-[:CALLS*]->` → REJECTED (unbounded — see rejection table) * * ## Compile target * * Single-node patterns compile to `searchGraph({ label })`. Fixed-length * relationship patterns compile to `traverse({ start, direction, edgeTypes, * maxDepth })`, filtering the returned hits to the relationship's depth * range. VARIABLE-length patterns (`*M..N` / `*N`) compile to the path- * enumerating primitive `traversePaths` (issue #1650) so an exact `*N` * honors concrete length-N paths; endpoints are filtered by PATH LENGTH * and deduped by node id. Property filters in node patterns * (`{name: "foo"}`) and WHERE conditions are applied in JS as post-filters * on the bound nodes. * * Variable-length patterns enumerate concrete relationship-simple paths via * `traversePaths` (issue #1650). Each path is cycle-safe under RELATIONSHIP * UNIQUENESS (a single path never reuses an edge), capped at `maxHops` and a * total-path cap. `*M..N` returns a node when a path of length in `[M, N]` * reaches it; exact `*N` (N > 1) thus includes a node reachable at BOTH a * shorter and a length-N path (the length-N path qualifies). The result is * deduped by node id, so `*1..N` ("reachable within N hops") is unchanged * from the prior BFS behavior — only exact `*N` (N > 1) gains paths the * shortest-depth BFS dropped. If enumeration hits the store's maxPaths cap, * the success result carries `truncated: true` so callers can detect a * partial endpoint set instead of silently dropping reachable nodes. * * ## Read-only by construction * * The parser only recognizes the tokens `MATCH`, `WHERE`, `RETURN`, * `LIMIT`, `AND`, `OR`, `true`, `false`, `null`. Every write/mutation * clause token (`CREATE`, `MERGE`, `SET`, `DELETE`, `DETACH`, `REMOVE`, * `DROP`, `CALL`, `YIELD`, `UNION`, `WITH`, `ORDER`, `BY`, `SKIP`, * `OPTIONAL`, `EXPLAIN`, `PROFILE`, `USE`, `FOREACH`, `LOAD`, * `CONSTRAINT`, `INDEX`) is rejected with a clear error naming the * supported grammar (rule 51). The module has no code path that writes * to the store. * * ## Rejection table (each has a dedicated test) * * - `CREATE (n:Function)` → unsupported clause (read-only) * - `MATCH (n) DELETE n` → unsupported clause * - `MATCH (n) SET n.x = 1` → unsupported clause * - `MATCH (a)-[:CALLS*]->(b) ...` → unbounded `*` (must specify range) * - `MATCH (a:NotALabel) ...` → unknown label (lists valid options) * - `MATCH (a) RETURN *` → `RETURN *` not in subset * - `MATCH (a)-[:CALLS]->(b) RETURN a, c` → unbound variable `c` * - `MATCH (a:Function {name: 123}) ...` → wrong-type literal matches * nothing (standard Cypher; * NOT a parse error — a numeric * `name` never equals a string) * - `MATCH (a:Function WHERE ...` → missing `)` / missing RETURN * - `MATCH (a:Function)` (no RETURN) → missing RETURN * * ## Scale caveat * * The subset is aimed at interactive exploration over indexed graphs. The * start-node resolution uses `searchGraph` (capped at 1000 rows by the * store); the inline `name`/`filePath` property filter and a supported * single-conjunction first-variable WHERE equality term are pushed down * to the index BEFORE the cap, so an exact-name lookup is found even when * the matching node sorts after the cap on a large graph. Values * containing LIKE metacharacters (`%`/`_`) and multi-group (OR) WHERE * clauses are NOT pushed down — they fall back to the capped scan, so on * graphs with more than 1000 nodes of the starting label such queries can * still false-negative; use the inline literal form for guaranteed * Relationship expansion uses `traverse` (fixed hops) or `traversePaths` * (variable length, issue #1650); both are cycle-safe and depth/length * capped. See the compile-target note for the path-length semantics of * variable-length `*N`. */ /** * PascalCase Cypher label → the lowercase value stored in `nodes.label`. * Labels whose DB form is not produced by ingest still map to a sensible * storage key so the query is structurally valid (returns empty). */ declare const CYPHER_LABEL_TO_DB_LABEL: Record; /** Sorted list of accepted Cypher labels — used in rejection messages. */ declare const VALID_CYPHER_LABELS: readonly string[]; /** * A value projected by RETURN. Strings, numbers, booleans, or null. Whole * nodes are returned as {@link CypherNodeValue} so callers can read every * field without re-querying. */ type CypherScalar = string | number | boolean | null; /** A whole-node value (RETURN `var` with no property). */ interface CypherNodeValue { nodeId: string; qualifiedName: string; name: string; label: string; filePath: string; } type CypherValue = CypherScalar | CypherNodeValue; /** One result row — a map from RETURN-item column name to its value. */ type CypherRow = Record; /** Failure codes. Distinct from the store codes — Cypher has its own. */ type CypherFailureCode = "parse_error" | "unsupported_clause" | "unknown_label" | "unbound_variable" | "invalid_query" | "store_closed" | "db_locked" | "db_corrupt" | "db_error"; interface CypherFailure { ok: false; code: CypherFailureCode; /** Human-readable explanation including the supported grammar hint. */ message: string; /** * Present only for `unknown_label`: the accepted label list, so callers * can render a completion menu without re-deriving it. */ validLabels?: readonly string[]; } interface CypherSuccess { ok: true; /** Column names in RETURN order; each row has these keys. */ columns: string[]; rows: CypherRow[]; /** * Present and `true` ONLY when a variable-length expansion hit the * `traversePaths` maxPaths cap — the rows are a PARTIAL endpoint set and * some reachable nodes may be omitted. Callers that must know the result * is complete should treat `truncated: true` as unreliable. Absent means * the enumeration completed (issue #1650). */ truncated?: boolean; } type CypherResult = CypherSuccess | CypherFailure; interface NodePattern { /** Variable name; undefined for an anonymous node `( )`. */ varName?: string; /** PascalCase label as written (`Function`, `Class`, ...). */ label?: string; /** Inline property filters from `{key: value, ...}`. */ properties: Array<{ key: string; value: CypherScalar; }>; } type RelDirection = "outgoing" | "incoming" | "both"; interface RelPattern { direction: RelDirection; /** Edge types; empty means "any type". */ types: string[]; /** Inclusive minimum hop count (default 1). */ minHops: number; /** Inclusive maximum hop count. Equal to minHops when `*N` form used. */ maxHops: number; /** True when a `*` range was parsed (variable-length). Drives the path-enumerating compile target (issue #1650). */ isVarLength: boolean; } interface Comparison { varName: string; key: string; op: "=" | "<>" | "!=" | ">" | "<" | ">=" | "<="; value: CypherScalar; } type WhereTerm = Comparison; interface WhereClause { /** Flat OR-of-AND-of-terms. We support AND+OR; no precedence gymnastics. */ orGroups: WhereTerm[][]; } type ReturnItem = { kind: "var"; varName: string; } | { kind: "prop"; varName: string; key: string; }; interface MatchClause { nodes: NodePattern[]; rels: RelPattern[]; } interface CypherAst { match: MatchClause; where?: WhereClause; return: ReturnItem[]; limit?: number; } declare class CypherParseError extends Error { readonly code: CypherFailureCode; readonly pos: number; readonly validLabels?: readonly string[]; constructor(pos: number, message: string, code?: CypherFailureCode, validLabels?: readonly string[]); } type CypherParseResult = { ok: true; ast: CypherAst; } | CypherFailure; /** * Parse a Cypher query string into an AST without executing it. Use this * to validate query shape (e.g. at a tool boundary) before opening a * store. The AST is an opaque internal type; callers should treat it as * a handle to pass to {@link executeAst}. */ declare function parseCypher(query: string): CypherParseResult; /** * Execute a parsed AST against a store. Exposed so callers that already * hold an AST (e.g. a cached plan) can skip re-parsing. */ declare function executeAst(store: GraphStore, ast: CypherAst): CypherResult; /** * Parse and execute a Cypher query against a store. Convenience wrapper * around {@link parseCypher} + {@link executeAst}. * * @example * const r = executeCypher(store, 'MATCH (f:Function {name: "foo"})-[:CALLS*1..2]->(g) WHERE g.label = "function" RETURN f.name, g.qualifiedName LIMIT 5'); * if (r.ok) for (const row of r.rows) console.log(row); */ declare function executeCypher(store: GraphStore, query: string): CypherResult; export { CYPHER_LABEL_TO_DB_LABEL, type CypherAst, type CypherFailure, type CypherFailureCode, type CypherNodeValue, CypherParseError, type CypherParseResult, type CypherResult, type CypherRow, type CypherScalar, type CypherSuccess, type CypherValue, VALID_CYPHER_LABELS, executeAst, executeCypher, parseCypher };