@remnic/coding-graph 9.3.759
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -0
- package/dist/chunk-5I2DBHOQ.js +1042 -0
- package/dist/chunk-5I2DBHOQ.js.map +1 -0
- package/dist/chunk-CPYJACC5.js +1838 -0
- package/dist/chunk-CPYJACC5.js.map +1 -0
- package/dist/chunk-ZVCMIM4T.js +216 -0
- package/dist/chunk-ZVCMIM4T.js.map +1 -0
- package/dist/cypher/query-parser.d.ts +253 -0
- package/dist/cypher/query-parser.js +17 -0
- package/dist/cypher/query-parser.js.map +1 -0
- package/dist/graph-schema.d.ts +84 -0
- package/dist/graph-schema.js +17 -0
- package/dist/graph-schema.js.map +1 -0
- package/dist/graph-store.d.ts +938 -0
- package/dist/graph-store.js +16 -0
- package/dist/graph-store.js.map +1 -0
- package/dist/index.d.ts +1953 -0
- package/dist/index.js +3509 -0
- package/dist/index.js.map +1 -0
- package/grammars/tree-sitter-bash.wasm +0 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/package.json +79 -0
- package/src/co-change.test.ts +175 -0
- package/src/co-change.ts +167 -0
- package/src/cypher/query-parser.test.ts +1107 -0
- package/src/cypher/query-parser.ts +1692 -0
- package/src/detect-changes.test.ts +533 -0
- package/src/detect-changes.ts +367 -0
- package/src/engine/emit.ts +556 -0
- package/src/engine/engine.test.ts +1417 -0
- package/src/engine/engine.ts +182 -0
- package/src/engine/extractors.ts +486 -0
- package/src/engine/fixtures.ts +364 -0
- package/src/engine/language-sniff.ts +56 -0
- package/src/engine/parser-backend.ts +206 -0
- package/src/engine/utf16-offsets.ts +68 -0
- package/src/git-invoker.test.ts +116 -0
- package/src/git-invoker.ts +426 -0
- package/src/graph-schema.test.ts +541 -0
- package/src/graph-schema.ts +383 -0
- package/src/graph-store-pr2.test.ts +1879 -0
- package/src/graph-store.test.ts +1420 -0
- package/src/graph-store.ts +3489 -0
- package/src/index-status.test.ts +303 -0
- package/src/index-status.ts +135 -0
- package/src/index.ts +384 -0
- package/src/lsp/byte-position.ts +173 -0
- package/src/lsp/characterization.test.ts +174 -0
- package/src/lsp/client.test.ts +275 -0
- package/src/lsp/client.ts +484 -0
- package/src/lsp/config.ts +219 -0
- package/src/lsp/degradation.ts +86 -0
- package/src/lsp/fixtures/fake-server.mjs +198 -0
- package/src/lsp/framing.test.ts +180 -0
- package/src/lsp/framing.ts +177 -0
- package/src/lsp/resolution.test.ts +497 -0
- package/src/lsp/resolution.ts +483 -0
- package/src/lsp/status.ts +140 -0
- package/src/lsp/types.ts +167 -0
- package/src/reindex.test.ts +1038 -0
- package/src/reindex.ts +908 -0
- package/src/row-types.ts +45 -0
- package/src/semantic/canonical-text.test.ts +150 -0
- package/src/semantic/canonical-text.ts +219 -0
- package/src/semantic/config.ts +235 -0
- package/src/semantic/index.ts +78 -0
- package/src/semantic/minhash.test.ts +197 -0
- package/src/semantic/minhash.ts +261 -0
- package/src/semantic/semantic-query.ts +173 -0
- package/src/semantic/semantic.test.ts +1315 -0
- package/src/semantic/similarity.ts +268 -0
- package/src/semantic/types.ts +145 -0
- package/src/semantic/vectors.ts +235 -0
|
@@ -0,0 +1,1692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* openCypher read subset — hand-written recursive-descent parser + executor.
|
|
3
|
+
*
|
|
4
|
+
* Issue #1552 PR3. This module is the thin, deletable Cypher layer over the
|
|
5
|
+
* structured store API (`searchGraph` / `traverse`). It compiles a strict
|
|
6
|
+
* read-only subset of openCypher to those primitives — there is NO SQL
|
|
7
|
+
* string assembly from user input anywhere; the structured API already
|
|
8
|
+
* parameterizes every bind (rule 51).
|
|
9
|
+
*
|
|
10
|
+
* ## Supported grammar (strict subset)
|
|
11
|
+
*
|
|
12
|
+
* ```
|
|
13
|
+
* query := MATCH pattern [WHERE where_clause] RETURN return_list [LIMIT int]
|
|
14
|
+
* pattern := node_pattern (rel_pattern node_pattern)*
|
|
15
|
+
* node_pattern := '(' [var] [':' label] ['{' prop_map '}'] ')'
|
|
16
|
+
* prop_map := key ':' literal (',' key ':' literal)*
|
|
17
|
+
* rel_pattern := ('<-'? '--' bracket '--' '->'?)
|
|
18
|
+
* | ('<-' bracket '--') // incoming: <-[...]- (also <-[...]--)
|
|
19
|
+
* | ('--' bracket '->') // outgoing: -[...]-> (also --[...]->)
|
|
20
|
+
* | ('<-'? '--' bracket '--' '->'?) // canonical form
|
|
21
|
+
* bracket := '[' [':' type ('|' ':' type)*] ['*' range] ']'
|
|
22
|
+
* range := int ('..' int)? | '..' int
|
|
23
|
+
* where_clause := comparison ((AND | OR) comparison)*
|
|
24
|
+
* comparison := var '.' key op literal
|
|
25
|
+
* op := '=' | '<>' | '!=' | '>' | '<' | '>=' | '<='
|
|
26
|
+
* return_list := return_item (',' return_item)*
|
|
27
|
+
* return_item := var | var '.' key
|
|
28
|
+
* literal := string | number | 'true' | 'false' | 'null'
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* Direction (resolved from the dashes/arrows around the bracket):
|
|
32
|
+
* - `-[...]->` → outgoing (follow src→dst edges)
|
|
33
|
+
* - `<-[...]-` → incoming (follow dst→src edges)
|
|
34
|
+
* - `-[...]-` → both
|
|
35
|
+
*
|
|
36
|
+
* Variable-length hops:
|
|
37
|
+
* - `-[:CALLS*1..3]->` → between 1 and 3 CALLS hops (inclusive)
|
|
38
|
+
* - `-[:CALLS*2]->` → exactly 2 hops
|
|
39
|
+
* - `-[:CALLS..3]->` → 1..3 hops (default min = 1)
|
|
40
|
+
* - `-[:CALLS*]->` → REJECTED (unbounded — see rejection table)
|
|
41
|
+
*
|
|
42
|
+
* ## Compile target
|
|
43
|
+
*
|
|
44
|
+
* Single-node patterns compile to `searchGraph({ label })`. Fixed-length
|
|
45
|
+
* relationship patterns compile to `traverse({ start, direction, edgeTypes,
|
|
46
|
+
* maxDepth })`, filtering the returned hits to the relationship's depth
|
|
47
|
+
* range. VARIABLE-length patterns (`*M..N` / `*N`) compile to the path-
|
|
48
|
+
* enumerating primitive `traversePaths` (issue #1650) so an exact `*N`
|
|
49
|
+
* honors concrete length-N paths; endpoints are filtered by PATH LENGTH
|
|
50
|
+
* and deduped by node id. Property filters in node patterns
|
|
51
|
+
* (`{name: "foo"}`) and WHERE conditions are applied in JS as post-filters
|
|
52
|
+
* on the bound nodes.
|
|
53
|
+
*
|
|
54
|
+
* Variable-length patterns enumerate concrete relationship-simple paths via
|
|
55
|
+
* `traversePaths` (issue #1650). Each path is cycle-safe under RELATIONSHIP
|
|
56
|
+
* UNIQUENESS (a single path never reuses an edge), capped at `maxHops` and a
|
|
57
|
+
* total-path cap. `*M..N` returns a node when a path of length in `[M, N]`
|
|
58
|
+
* reaches it; exact `*N` (N > 1) thus includes a node reachable at BOTH a
|
|
59
|
+
* shorter and a length-N path (the length-N path qualifies). The result is
|
|
60
|
+
* deduped by node id, so `*1..N` ("reachable within N hops") is unchanged
|
|
61
|
+
* from the prior BFS behavior — only exact `*N` (N > 1) gains paths the
|
|
62
|
+
* shortest-depth BFS dropped. If enumeration hits the store's maxPaths cap,
|
|
63
|
+
* the success result carries `truncated: true` so callers can detect a
|
|
64
|
+
* partial endpoint set instead of silently dropping reachable nodes.
|
|
65
|
+
*
|
|
66
|
+
* ## Read-only by construction
|
|
67
|
+
*
|
|
68
|
+
* The parser only recognizes the tokens `MATCH`, `WHERE`, `RETURN`,
|
|
69
|
+
* `LIMIT`, `AND`, `OR`, `true`, `false`, `null`. Every write/mutation
|
|
70
|
+
* clause token (`CREATE`, `MERGE`, `SET`, `DELETE`, `DETACH`, `REMOVE`,
|
|
71
|
+
* `DROP`, `CALL`, `YIELD`, `UNION`, `WITH`, `ORDER`, `BY`, `SKIP`,
|
|
72
|
+
* `OPTIONAL`, `EXPLAIN`, `PROFILE`, `USE`, `FOREACH`, `LOAD`,
|
|
73
|
+
* `CONSTRAINT`, `INDEX`) is rejected with a clear error naming the
|
|
74
|
+
* supported grammar (rule 51). The module has no code path that writes
|
|
75
|
+
* to the store.
|
|
76
|
+
*
|
|
77
|
+
* ## Rejection table (each has a dedicated test)
|
|
78
|
+
*
|
|
79
|
+
* - `CREATE (n:Function)` → unsupported clause (read-only)
|
|
80
|
+
* - `MATCH (n) DELETE n` → unsupported clause
|
|
81
|
+
* - `MATCH (n) SET n.x = 1` → unsupported clause
|
|
82
|
+
* - `MATCH (a)-[:CALLS*]->(b) ...` → unbounded `*` (must specify range)
|
|
83
|
+
* - `MATCH (a:NotALabel) ...` → unknown label (lists valid options)
|
|
84
|
+
* - `MATCH (a) RETURN *` → `RETURN *` not in subset
|
|
85
|
+
* - `MATCH (a)-[:CALLS]->(b) RETURN a, c` → unbound variable `c`
|
|
86
|
+
* - `MATCH (a:Function {name: 123}) ...` → wrong-type literal matches
|
|
87
|
+
* nothing (standard Cypher;
|
|
88
|
+
* NOT a parse error — a numeric
|
|
89
|
+
* `name` never equals a string)
|
|
90
|
+
* - `MATCH (a:Function WHERE ...` → missing `)` / missing RETURN
|
|
91
|
+
* - `MATCH (a:Function)` (no RETURN) → missing RETURN
|
|
92
|
+
*
|
|
93
|
+
* ## Scale caveat
|
|
94
|
+
*
|
|
95
|
+
* The subset is aimed at interactive exploration over indexed graphs. The
|
|
96
|
+
* start-node resolution uses `searchGraph` (capped at 1000 rows by the
|
|
97
|
+
* store); the inline `name`/`filePath` property filter and a supported
|
|
98
|
+
* single-conjunction first-variable WHERE equality term are pushed down
|
|
99
|
+
* to the index BEFORE the cap, so an exact-name lookup is found even when
|
|
100
|
+
* the matching node sorts after the cap on a large graph. Values
|
|
101
|
+
* containing LIKE metacharacters (`%`/`_`) and multi-group (OR) WHERE
|
|
102
|
+
* clauses are NOT pushed down — they fall back to the capped scan, so on
|
|
103
|
+
* graphs with more than 1000 nodes of the starting label such queries can
|
|
104
|
+
* still false-negative; use the inline literal form for guaranteed
|
|
105
|
+
* Relationship expansion uses `traverse` (fixed hops) or `traversePaths`
|
|
106
|
+
* (variable length, issue #1650); both are cycle-safe and depth/length
|
|
107
|
+
* capped. See the compile-target note for the path-length semantics of
|
|
108
|
+
* variable-length `*N`.
|
|
109
|
+
*/
|
|
110
|
+
import type {
|
|
111
|
+
GraphStore,
|
|
112
|
+
SearchHit,
|
|
113
|
+
TraverseHit,
|
|
114
|
+
TraversePathHit,
|
|
115
|
+
} from "../graph-store.js";
|
|
116
|
+
|
|
117
|
+
import { MAX_TRAVERSE_PATHS_HOPS } from "../graph-store.js";
|
|
118
|
+
|
|
119
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
120
|
+
// Label universe — the documented schema's node labels.
|
|
121
|
+
//
|
|
122
|
+
// The store persists `nodes.label` as the symbol `kind` (lowercase:
|
|
123
|
+
// `function`, `class`, `method`, `interface`, `enum`, `type`, `module`).
|
|
124
|
+
// The remaining labels (`Project`, `Package`, `Folder`, `File`, `Route`,
|
|
125
|
+
// `Resource`) are part of the documented schema universe but are not
|
|
126
|
+
// emitted by the current ingest pipeline; queries against them simply
|
|
127
|
+
// return zero rows. We ACCEPT the full documented universe so the grammar
|
|
128
|
+
// matches the issue's stated 12+ label list, and REJECT everything outside
|
|
129
|
+
// it with a clear error (rejection table).
|
|
130
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* PascalCase Cypher label → the lowercase value stored in `nodes.label`.
|
|
134
|
+
* Labels whose DB form is not produced by ingest still map to a sensible
|
|
135
|
+
* storage key so the query is structurally valid (returns empty).
|
|
136
|
+
*/
|
|
137
|
+
const CYPHER_LABEL_TO_DB_LABEL: Record<string, string> = {
|
|
138
|
+
Project: "project",
|
|
139
|
+
Package: "package",
|
|
140
|
+
Folder: "folder",
|
|
141
|
+
File: "file",
|
|
142
|
+
Module: "module",
|
|
143
|
+
Class: "class",
|
|
144
|
+
Function: "function",
|
|
145
|
+
Method: "method",
|
|
146
|
+
Interface: "interface",
|
|
147
|
+
Enum: "enum",
|
|
148
|
+
Type: "type",
|
|
149
|
+
Route: "route",
|
|
150
|
+
Resource: "resource",
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** Sorted list of accepted Cypher labels — used in rejection messages. */
|
|
154
|
+
const VALID_CYPHER_LABELS: readonly string[] = Object.keys(
|
|
155
|
+
CYPHER_LABEL_TO_DB_LABEL,
|
|
156
|
+
).sort();
|
|
157
|
+
|
|
158
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
159
|
+
// Public result types.
|
|
160
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A value projected by RETURN. Strings, numbers, booleans, or null. Whole
|
|
164
|
+
* nodes are returned as {@link CypherNodeValue} so callers can read every
|
|
165
|
+
* field without re-querying.
|
|
166
|
+
*/
|
|
167
|
+
export type CypherScalar = string | number | boolean | null;
|
|
168
|
+
|
|
169
|
+
/** A whole-node value (RETURN `var` with no property). */
|
|
170
|
+
export interface CypherNodeValue {
|
|
171
|
+
nodeId: string;
|
|
172
|
+
qualifiedName: string;
|
|
173
|
+
name: string;
|
|
174
|
+
label: string;
|
|
175
|
+
filePath: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export type CypherValue = CypherScalar | CypherNodeValue;
|
|
179
|
+
|
|
180
|
+
/** One result row — a map from RETURN-item column name to its value. */
|
|
181
|
+
export type CypherRow = Record<string, CypherValue>;
|
|
182
|
+
|
|
183
|
+
/** Failure codes. Distinct from the store codes — Cypher has its own. */
|
|
184
|
+
export type CypherFailureCode =
|
|
185
|
+
| "parse_error" // generic grammar failure
|
|
186
|
+
| "unsupported_clause" // CREATE / SET / DELETE / unbounded `*` / RETURN *
|
|
187
|
+
| "unknown_label" // label not in the documented universe
|
|
188
|
+
| "unbound_variable" // RETURN / WHERE references a var not in MATCH
|
|
189
|
+
| "invalid_query" // structurally parsed but semantically bad (bad range, etc.)
|
|
190
|
+
| "store_closed"
|
|
191
|
+
| "db_locked"
|
|
192
|
+
| "db_corrupt"
|
|
193
|
+
| "db_error";
|
|
194
|
+
|
|
195
|
+
export interface CypherFailure {
|
|
196
|
+
ok: false;
|
|
197
|
+
code: CypherFailureCode;
|
|
198
|
+
/** Human-readable explanation including the supported grammar hint. */
|
|
199
|
+
message: string;
|
|
200
|
+
/**
|
|
201
|
+
* Present only for `unknown_label`: the accepted label list, so callers
|
|
202
|
+
* can render a completion menu without re-deriving it.
|
|
203
|
+
*/
|
|
204
|
+
validLabels?: readonly string[];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface CypherSuccess {
|
|
208
|
+
ok: true;
|
|
209
|
+
/** Column names in RETURN order; each row has these keys. */
|
|
210
|
+
columns: string[];
|
|
211
|
+
rows: CypherRow[];
|
|
212
|
+
/**
|
|
213
|
+
* Present and `true` ONLY when a variable-length expansion hit the
|
|
214
|
+
* `traversePaths` maxPaths cap — the rows are a PARTIAL endpoint set and
|
|
215
|
+
* some reachable nodes may be omitted. Callers that must know the result
|
|
216
|
+
* is complete should treat `truncated: true` as unreliable. Absent means
|
|
217
|
+
* the enumeration completed (issue #1650).
|
|
218
|
+
*/
|
|
219
|
+
truncated?: boolean;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export type CypherResult = CypherSuccess | CypherFailure;
|
|
223
|
+
|
|
224
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
225
|
+
// AST types.
|
|
226
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
interface NodePattern {
|
|
229
|
+
/** Variable name; undefined for an anonymous node `( )`. */
|
|
230
|
+
varName?: string;
|
|
231
|
+
/** PascalCase label as written (`Function`, `Class`, ...). */
|
|
232
|
+
label?: string;
|
|
233
|
+
/** Inline property filters from `{key: value, ...}`. */
|
|
234
|
+
properties: Array<{ key: string; value: CypherScalar }>;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
type RelDirection = "outgoing" | "incoming" | "both";
|
|
238
|
+
|
|
239
|
+
interface RelPattern {
|
|
240
|
+
direction: RelDirection;
|
|
241
|
+
/** Edge types; empty means "any type". */
|
|
242
|
+
types: string[];
|
|
243
|
+
/** Inclusive minimum hop count (default 1). */
|
|
244
|
+
minHops: number;
|
|
245
|
+
/** Inclusive maximum hop count. Equal to minHops when `*N` form used. */
|
|
246
|
+
maxHops: number;
|
|
247
|
+
/** True when a `*` range was parsed (variable-length). Drives the path-enumerating compile target (issue #1650). */
|
|
248
|
+
isVarLength: boolean;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
interface Comparison {
|
|
252
|
+
varName: string;
|
|
253
|
+
key: string;
|
|
254
|
+
op: "=" | "<>" | "!=" | ">" | "<" | ">=" | "<=";
|
|
255
|
+
value: CypherScalar;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
type WhereTerm = Comparison;
|
|
259
|
+
interface WhereClause {
|
|
260
|
+
/** Flat OR-of-AND-of-terms. We support AND+OR; no precedence gymnastics. */
|
|
261
|
+
orGroups: WhereTerm[][];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
type ReturnItem =
|
|
265
|
+
| { kind: "var"; varName: string }
|
|
266
|
+
| { kind: "prop"; varName: string; key: string };
|
|
267
|
+
|
|
268
|
+
interface MatchClause {
|
|
269
|
+
nodes: NodePattern[];
|
|
270
|
+
rels: RelPattern[]; // length === nodes.length - 1
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
interface CypherAst {
|
|
274
|
+
match: MatchClause;
|
|
275
|
+
where?: WhereClause;
|
|
276
|
+
return: ReturnItem[];
|
|
277
|
+
limit?: number;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
281
|
+
// Tokenizer.
|
|
282
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
type TokenType =
|
|
285
|
+
| "WORD" // identifier OR keyword (disambiguated in parser)
|
|
286
|
+
| "STRING"
|
|
287
|
+
| "NUMBER"
|
|
288
|
+
| "LPAREN"
|
|
289
|
+
| "RPAREN"
|
|
290
|
+
| "LBRACK"
|
|
291
|
+
| "RBRACK"
|
|
292
|
+
| "LBRACE"
|
|
293
|
+
| "RBRACE"
|
|
294
|
+
| "COLON"
|
|
295
|
+
| "PIPE"
|
|
296
|
+
| "COMMA"
|
|
297
|
+
| "DOT"
|
|
298
|
+
| "DOTDOT"
|
|
299
|
+
| "STAR"
|
|
300
|
+
| "ARROW_R" // ->
|
|
301
|
+
| "ARROW_L" // <-
|
|
302
|
+
| "DASHDASH" // --
|
|
303
|
+
| "DASH" // - (only meaningful before a number for negation)
|
|
304
|
+
| "EQ" // =
|
|
305
|
+
| "NE" // <> or !=
|
|
306
|
+
| "GT"
|
|
307
|
+
| "LT"
|
|
308
|
+
| "GE"
|
|
309
|
+
| "LE"
|
|
310
|
+
| "EOF";
|
|
311
|
+
|
|
312
|
+
interface Token {
|
|
313
|
+
type: TokenType;
|
|
314
|
+
/** Raw source text of the token (for identifiers, strings, numbers). */
|
|
315
|
+
text: string;
|
|
316
|
+
/** 0-based offset in the source, for error messages. */
|
|
317
|
+
pos: number;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const KEYWORDS: Record<string, true> = {
|
|
321
|
+
match: true,
|
|
322
|
+
where: true,
|
|
323
|
+
return: true,
|
|
324
|
+
limit: true,
|
|
325
|
+
and: true,
|
|
326
|
+
or: true,
|
|
327
|
+
true: true,
|
|
328
|
+
false: true,
|
|
329
|
+
null: true,
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Tokens whose presence ANYWHERE in the query unambiguously signals a
|
|
334
|
+
* write/mutation intent or a clause outside the read subset. They are
|
|
335
|
+
* rejected with `unsupported_clause` carrying a tailored message.
|
|
336
|
+
*/
|
|
337
|
+
const WRITE_OR_OUTSIDE_CLAUSES: Record<string, string> = {
|
|
338
|
+
create: "CREATE is a write clause; this Cypher layer is read-only (MATCH/WHERE/RETURN/LIMIT only).",
|
|
339
|
+
merge: "MERGE is a write clause; this Cypher layer is read-only.",
|
|
340
|
+
set: "SET is a write clause; this Cypher layer is read-only.",
|
|
341
|
+
delete: "DELETE is a write clause; this Cypher layer is read-only.",
|
|
342
|
+
detach: "DETACH DELETE is a write clause; this Cypher layer is read-only.",
|
|
343
|
+
remove: "REMOVE is a write clause; this Cypher layer is read-only.",
|
|
344
|
+
drop: "DROP is a write clause; this Cypher layer is read-only.",
|
|
345
|
+
call: "CALL { ... } subqueries are outside the read subset.",
|
|
346
|
+
yield: "YIELD is outside the read subset (only MATCH/WHERE/RETURN/LIMIT).",
|
|
347
|
+
union: "UNION combinator is outside the read subset.",
|
|
348
|
+
with: "WITH is outside the read subset (only MATCH/WHERE/RETURN/LIMIT).",
|
|
349
|
+
order: "ORDER BY is outside the read subset (use LIMIT, or post-sort in the caller).",
|
|
350
|
+
by: "ORDER BY is outside the read subset.",
|
|
351
|
+
skip: "SKIP is outside the read subset (use LIMIT).",
|
|
352
|
+
optional: "OPTIONAL MATCH is outside the read subset.",
|
|
353
|
+
explain: "EXPLAIN is outside the read subset.",
|
|
354
|
+
profile: "PROFILE is outside the read subset.",
|
|
355
|
+
use: "USE (graph routing) is outside the read subset.",
|
|
356
|
+
foreach: "FOREACH is a write clause; this Cypher layer is read-only.",
|
|
357
|
+
load: "LOAD CSV / LOAD FROM is outside the read subset.",
|
|
358
|
+
constraint: "CONSTRAINT is a DDL clause; this Cypher layer is read-only.",
|
|
359
|
+
index: "INDEX is a DDL clause; this Cypher layer is read-only.",
|
|
360
|
+
graph: "GRAPH is outside the read subset.",
|
|
361
|
+
unwind: "UNWIND is outside the read subset.",
|
|
362
|
+
distinct: "DISTINCT is outside the read subset (this layer does not dedupe).",
|
|
363
|
+
exists: "EXISTS is outside the read subset (use property comparisons).",
|
|
364
|
+
in: "IN is outside the read subset (use a property comparison).",
|
|
365
|
+
is_: "IS NULL / IS NOT NULL is outside the read subset.",
|
|
366
|
+
not: "NOT is outside the read subset (use positive comparisons or <>).",
|
|
367
|
+
starts: "STARTS WITH is outside the read subset.",
|
|
368
|
+
ends: "ENDS WITH is outside the read subset.",
|
|
369
|
+
contains: "CONTAINS is outside the read subset.",
|
|
370
|
+
regex: "=~ regex is outside the read subset.",
|
|
371
|
+
count: "COUNT(...) aggregation is outside the read subset.",
|
|
372
|
+
sum: "SUM(...) aggregation is outside the read subset.",
|
|
373
|
+
min: "MIN(...) aggregation is outside the read subset.",
|
|
374
|
+
max: "MAX(...) aggregation is outside the read subset.",
|
|
375
|
+
avg: "AVG(...) aggregation is outside the read subset.",
|
|
376
|
+
collect: "COLLECT(...) aggregation is outside the read subset.",
|
|
377
|
+
case: "CASE expressions are outside the read subset.",
|
|
378
|
+
when: "CASE expressions are outside the read subset.",
|
|
379
|
+
then: "CASE expressions are outside the read subset.",
|
|
380
|
+
else: "CASE expressions are outside the read subset.",
|
|
381
|
+
end: "CASE ... END is outside the read subset.",
|
|
382
|
+
as: "AS aliasing is outside the read subset (RETURN items project under their literal text).",
|
|
383
|
+
reduce: "REDUCE is outside the read subset.",
|
|
384
|
+
shortestpath: "shortestPath() is outside the read subset.",
|
|
385
|
+
all: "ALL(...) pattern predicate is outside the read subset.",
|
|
386
|
+
any: "ANY(...) pattern predicate is outside the read subset.",
|
|
387
|
+
none: "NONE(...) pattern predicate is outside the read subset.",
|
|
388
|
+
single: "SINGLE(...) pattern predicate is outside the read subset.",
|
|
389
|
+
size: "SIZE() is outside the read subset.",
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
class Tokenizer {
|
|
393
|
+
private readonly src: string;
|
|
394
|
+
private i = 0;
|
|
395
|
+
private readonly tokens: Token[] = [];
|
|
396
|
+
|
|
397
|
+
constructor(src: string) {
|
|
398
|
+
this.src = src;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
tokenize(): Token[] {
|
|
402
|
+
while (this.i < this.src.length) {
|
|
403
|
+
const ch = this.src[this.i]!;
|
|
404
|
+
// Whitespace.
|
|
405
|
+
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
|
|
406
|
+
this.i += 1;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
// Line comments `//` (Cypher) and block comments `/* */`.
|
|
410
|
+
if (ch === "/" && this.src[this.i + 1] === "/") {
|
|
411
|
+
const nl = this.src.indexOf("\n", this.i);
|
|
412
|
+
this.i = nl === -1 ? this.src.length : nl + 1;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (ch === "/" && this.src[this.i + 1] === "*") {
|
|
416
|
+
const end = this.src.indexOf("*/", this.i + 2);
|
|
417
|
+
if (end === -1) {
|
|
418
|
+
throw parseError(
|
|
419
|
+
this.i,
|
|
420
|
+
"Unterminated block comment. Supported grammar: MATCH/WHERE/RETURN/LIMIT.",
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
this.i = end + 2;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const start = this.i;
|
|
427
|
+
// Strings — double or single quoted. Backslash escapes are preserved
|
|
428
|
+
// literally (the subset doesn't need them); the closing quote is the
|
|
429
|
+
// next unescaped matching quote.
|
|
430
|
+
if (ch === '"' || ch === "'") {
|
|
431
|
+
this.readString(ch, start);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
// Numbers — digits, or `.`-leading fractional. A leading `-` is
|
|
435
|
+
// emitted as a DASH token so the parser can negate a NUMBER in the
|
|
436
|
+
// literal position (the tokenizer never looks that far ahead).
|
|
437
|
+
if (isDigit(ch) || (ch === "." && isDigit(this.src[this.i + 1]!))) {
|
|
438
|
+
this.readNumber(start);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
// Identifiers / keywords — [A-Za-z_][A-Za-z0-9_]*
|
|
442
|
+
if (isIdentStart(ch)) {
|
|
443
|
+
this.readWord(start);
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
// Punctuation & operators — maximal munch.
|
|
447
|
+
const two = this.src.slice(this.i, this.i + 2);
|
|
448
|
+
const three = this.src.slice(this.i, this.i + 3);
|
|
449
|
+
if (three === "-->") {
|
|
450
|
+
this.push("ARROW_R", three, start);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (three === "<--") {
|
|
454
|
+
this.push("ARROW_L", three, start);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (two === "->") {
|
|
458
|
+
this.push("ARROW_R", two, start);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (two === "<-") {
|
|
462
|
+
this.push("ARROW_L", two, start);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (two === "--") {
|
|
466
|
+
this.push("DASHDASH", two, start);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
if (two === "..") {
|
|
470
|
+
this.push("DOTDOT", two, start);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (two === "<>" || two === "!=") {
|
|
474
|
+
this.push("NE", two, start);
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (two === ">=") {
|
|
478
|
+
this.push("GE", two, start);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (two === "<=") {
|
|
482
|
+
this.push("LE", two, start);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
switch (ch) {
|
|
486
|
+
case "(":
|
|
487
|
+
this.push("LPAREN", ch, start);
|
|
488
|
+
break;
|
|
489
|
+
case ")":
|
|
490
|
+
this.push("RPAREN", ch, start);
|
|
491
|
+
break;
|
|
492
|
+
case "[":
|
|
493
|
+
this.push("LBRACK", ch, start);
|
|
494
|
+
break;
|
|
495
|
+
case "]":
|
|
496
|
+
this.push("RBRACK", ch, start);
|
|
497
|
+
break;
|
|
498
|
+
case "{":
|
|
499
|
+
this.push("LBRACE", ch, start);
|
|
500
|
+
break;
|
|
501
|
+
case "}":
|
|
502
|
+
this.push("RBRACE", ch, start);
|
|
503
|
+
break;
|
|
504
|
+
case ":":
|
|
505
|
+
this.push("COLON", ch, start);
|
|
506
|
+
break;
|
|
507
|
+
case "|":
|
|
508
|
+
this.push("PIPE", ch, start);
|
|
509
|
+
break;
|
|
510
|
+
case ",":
|
|
511
|
+
this.push("COMMA", ch, start);
|
|
512
|
+
break;
|
|
513
|
+
case ".":
|
|
514
|
+
this.push("DOT", ch, start);
|
|
515
|
+
break;
|
|
516
|
+
case "*":
|
|
517
|
+
this.push("STAR", ch, start);
|
|
518
|
+
break;
|
|
519
|
+
case "-":
|
|
520
|
+
this.push("DASH", ch, start);
|
|
521
|
+
break;
|
|
522
|
+
case "=":
|
|
523
|
+
this.push("EQ", ch, start);
|
|
524
|
+
break;
|
|
525
|
+
case ">":
|
|
526
|
+
this.push("GT", ch, start);
|
|
527
|
+
break;
|
|
528
|
+
case "<":
|
|
529
|
+
this.push("LT", ch, start);
|
|
530
|
+
break;
|
|
531
|
+
default:
|
|
532
|
+
throw parseError(
|
|
533
|
+
start,
|
|
534
|
+
`Unexpected character ${JSON.stringify(ch)}. Supported grammar: MATCH (a:Label {p: "v"})-[:TYPE*1..3]->(b) WHERE ... RETURN ... LIMIT n.`,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
this.push("EOF", "", this.i);
|
|
539
|
+
return this.tokens;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private push(type: TokenType, text: string, pos: number): void {
|
|
543
|
+
this.tokens.push({ type, text, pos });
|
|
544
|
+
this.i = pos + text.length;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private readString(quote: string, start: number): void {
|
|
548
|
+
let j = start + 1;
|
|
549
|
+
const chars: string[] = [];
|
|
550
|
+
while (j < this.src.length) {
|
|
551
|
+
const c = this.src[j]!;
|
|
552
|
+
if (c === "\\") {
|
|
553
|
+
// Preserve the escape sequence verbatim — the subset doesn't
|
|
554
|
+
// interpret \n / \t / \" / \\; it stores and matches the raw two
|
|
555
|
+
// characters. This is documented and tested.
|
|
556
|
+
chars.push(c);
|
|
557
|
+
chars.push(this.src[j + 1] ?? "");
|
|
558
|
+
j += 2;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (c === quote) {
|
|
562
|
+
const text = chars.join("");
|
|
563
|
+
this.tokens.push({ type: "STRING", text, pos: start });
|
|
564
|
+
this.i = j + 1;
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
chars.push(c);
|
|
568
|
+
j += 1;
|
|
569
|
+
}
|
|
570
|
+
throw parseError(start, "Unterminated string literal.");
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private readNumber(start: number): void {
|
|
574
|
+
let j = start;
|
|
575
|
+
while (j < this.src.length && isDigit(this.src[j]!)) j += 1;
|
|
576
|
+
if (this.src[j] === "." && isDigit(this.src[j + 1]!)) {
|
|
577
|
+
j += 1;
|
|
578
|
+
while (j < this.src.length && isDigit(this.src[j]!)) j += 1;
|
|
579
|
+
}
|
|
580
|
+
// Exponent (e.g. 1e3). Rare in property filters but cheap to accept.
|
|
581
|
+
if (this.src[j] === "e" || this.src[j] === "E") {
|
|
582
|
+
j += 1;
|
|
583
|
+
if (this.src[j] === "+" || this.src[j] === "-") j += 1;
|
|
584
|
+
while (j < this.src.length && isDigit(this.src[j]!)) j += 1;
|
|
585
|
+
}
|
|
586
|
+
const text = this.src.slice(start, j);
|
|
587
|
+
this.tokens.push({ type: "NUMBER", text, pos: start });
|
|
588
|
+
this.i = j;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private readWord(start: number): void {
|
|
592
|
+
let j = start;
|
|
593
|
+
while (j < this.src.length && isIdentPart(this.src[j]!)) j += 1;
|
|
594
|
+
const text = this.src.slice(start, j);
|
|
595
|
+
this.tokens.push({ type: "WORD", text, pos: start });
|
|
596
|
+
this.i = j;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function isDigit(c: string): boolean {
|
|
601
|
+
return c >= "0" && c <= "9";
|
|
602
|
+
}
|
|
603
|
+
function isIdentStart(c: string): boolean {
|
|
604
|
+
return (
|
|
605
|
+
(c >= "a" && c <= "z") ||
|
|
606
|
+
(c >= "A" && c <= "Z") ||
|
|
607
|
+
c === "_"
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
function isIdentPart(c: string): boolean {
|
|
611
|
+
return isIdentStart(c) || isDigit(c);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
615
|
+
// Parser.
|
|
616
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
617
|
+
|
|
618
|
+
class Parser {
|
|
619
|
+
private readonly tokens: Token[];
|
|
620
|
+
private i = 0;
|
|
621
|
+
|
|
622
|
+
constructor(tokens: Token[]) {
|
|
623
|
+
this.tokens = tokens;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
parse(): CypherAst {
|
|
627
|
+
// Cypher queries in the subset MUST start with MATCH. Any other
|
|
628
|
+
// leading token — including a write clause like CREATE — is rejected
|
|
629
|
+
// here with the supported-grammar hint.
|
|
630
|
+
this.expectKeyword("match");
|
|
631
|
+
const match = this.parseMatch();
|
|
632
|
+
|
|
633
|
+
let where: WhereClause | undefined;
|
|
634
|
+
if (this.consumeKeyword("where")) {
|
|
635
|
+
where = this.parseWhere();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
this.expectKeyword("return");
|
|
639
|
+
const returnItems = this.parseReturn();
|
|
640
|
+
|
|
641
|
+
let limit: number | undefined;
|
|
642
|
+
if (this.consumeKeyword("limit")) {
|
|
643
|
+
limit = this.parseNonNegativeInt("LIMIT");
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Anything after LIMIT (other than EOF) is outside the subset.
|
|
647
|
+
const tok = this.peek();
|
|
648
|
+
if (tok.type !== "EOF") {
|
|
649
|
+
throw this.unsupportedAfter(tok);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
this.validateAst(match, where, returnItems);
|
|
653
|
+
return { match, where, return: returnItems, limit };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ── MATCH / pattern ────────────────────────────────────────────────────
|
|
657
|
+
|
|
658
|
+
private parseMatch(): MatchClause {
|
|
659
|
+
const nodes: NodePattern[] = [this.parseNode()];
|
|
660
|
+
const rels: RelPattern[] = [];
|
|
661
|
+
// A pattern continues while the next token begins a relationship.
|
|
662
|
+
while (this.startsWithRelationship()) {
|
|
663
|
+
const rel = this.parseRelationship();
|
|
664
|
+
nodes.push(this.parseNode());
|
|
665
|
+
rels.push(rel);
|
|
666
|
+
}
|
|
667
|
+
return { nodes, rels };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
private startsWithRelationship(): boolean {
|
|
671
|
+
const t = this.peek();
|
|
672
|
+
// A relationship starts with a left-side dash run: `<-` (ARROW_L),
|
|
673
|
+
// `--` (DASHDASH), or `-` (DASH). A bare `<` (LT) is NOT a valid
|
|
674
|
+
// relationship start — incoming uses `<-`, tokenized as ARROW_L.
|
|
675
|
+
return (
|
|
676
|
+
t.type === "ARROW_L" ||
|
|
677
|
+
t.type === "DASHDASH" ||
|
|
678
|
+
t.type === "DASH"
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private parseNode(): NodePattern {
|
|
683
|
+
this.expect("LPAREN", "a node pattern `(var:Label {p: \"v\"})`");
|
|
684
|
+
let varName: string | undefined;
|
|
685
|
+
const first = this.peek();
|
|
686
|
+
if (first.type === "WORD" && !this.isKeyword(first.text)) {
|
|
687
|
+
varName = first.text;
|
|
688
|
+
this.advance();
|
|
689
|
+
}
|
|
690
|
+
let label: string | undefined;
|
|
691
|
+
if (this.peek().type === "COLON") {
|
|
692
|
+
this.advance();
|
|
693
|
+
const lt = this.expect("WORD", "a label after `:`");
|
|
694
|
+
label = lt.text;
|
|
695
|
+
}
|
|
696
|
+
const properties: Array<{ key: string; value: CypherScalar }> = [];
|
|
697
|
+
if (this.peek().type === "LBRACE") {
|
|
698
|
+
this.advance();
|
|
699
|
+
// Empty `{}` is allowed.
|
|
700
|
+
if (this.peek().type !== "RBRACE") {
|
|
701
|
+
properties.push(this.parseProp());
|
|
702
|
+
while (this.peek().type === "COMMA") {
|
|
703
|
+
this.advance();
|
|
704
|
+
properties.push(this.parseProp());
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
this.expect("RBRACE", "closing `}` of the property map");
|
|
708
|
+
}
|
|
709
|
+
this.expect("RPAREN", "closing `)` of the node pattern");
|
|
710
|
+
return { varName, label, properties };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private parseProp(): { key: string; value: CypherScalar } {
|
|
714
|
+
const keyTok = this.expect("WORD", "a property key");
|
|
715
|
+
this.expect("COLON", "`:` between property key and value");
|
|
716
|
+
const value = this.parseLiteral();
|
|
717
|
+
return { key: keyTok.text, value };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Parse `<leftDash> [bracket] <rightDash>`. Direction is resolved from
|
|
722
|
+
* the two dash runs: `-[...]->` outgoing, `<-[...]-` incoming,
|
|
723
|
+
* `-[...]-` / `--[...]--` both. The bracket is required.
|
|
724
|
+
*/
|
|
725
|
+
private parseRelationship(): RelPattern {
|
|
726
|
+
const leftDir = this.consumeLeftDashRun();
|
|
727
|
+
// The bracket `[...]` is REQUIRED in the subset — bare `-->` without
|
|
728
|
+
// a bracket is rejected because the grammar documents the bracketed
|
|
729
|
+
// form and we want one shape to test.
|
|
730
|
+
if (this.peek().type !== "LBRACK") {
|
|
731
|
+
throw parseError(
|
|
732
|
+
this.peek().pos,
|
|
733
|
+
"Relationships must use a bracketed form, e.g. `-[:CALLS]->` or `-[:CALLS*1..3]->`. Bare arrows without `[...]` are not in the subset.",
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
this.advance(); // consume [
|
|
737
|
+
const types: string[] = [];
|
|
738
|
+
if (this.peek().type === "COLON") {
|
|
739
|
+
this.advance();
|
|
740
|
+
types.push(this.expect("WORD", "an edge type after `:[`").text);
|
|
741
|
+
while (this.peek().type === "PIPE") {
|
|
742
|
+
this.advance();
|
|
743
|
+
// openCypher accepts both `:A|:B` and `:A|B`; the colon after `|`
|
|
744
|
+
// is optional. Consume it if present, else read the type directly.
|
|
745
|
+
if (this.peek().type === "COLON") this.advance();
|
|
746
|
+
types.push(this.expect("WORD", "an edge type after `|`").text);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
let minHops = 1;
|
|
750
|
+
let maxHops = 1;
|
|
751
|
+
let isVarLength = false;
|
|
752
|
+
if (this.peek().type === "STAR") {
|
|
753
|
+
this.advance();
|
|
754
|
+
const range = this.parseRange();
|
|
755
|
+
minHops = range.min;
|
|
756
|
+
maxHops = range.max;
|
|
757
|
+
isVarLength = true;
|
|
758
|
+
}
|
|
759
|
+
this.expect("RBRACK", "closing `]` of the relationship bracket");
|
|
760
|
+
|
|
761
|
+
const rightDir = this.consumeRightDashRun();
|
|
762
|
+
if (leftDir === "none" && rightDir === "none") {
|
|
763
|
+
throw parseError(
|
|
764
|
+
this.peek().pos,
|
|
765
|
+
"Relationships require dashes around the bracket, e.g. `-[:CALLS]->`, `<-[:CALLS]-`, or `-[:CALLS]-`. Bare `[...]` between nodes is not valid.",
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const direction = resolveDirection(leftDir, rightDir);
|
|
770
|
+
if (maxHops < minHops) {
|
|
771
|
+
throw parseError(
|
|
772
|
+
this.peek().pos,
|
|
773
|
+
`Relationship range max (${maxHops}) is less than min (${minHops}).`,
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
return { direction, types, minHops, maxHops, isVarLength };
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
private parseRange(): { min: number; max: number } {
|
|
780
|
+
// Forms accepted:
|
|
781
|
+
// N → exactly N (min=max=N)
|
|
782
|
+
// N..M → min=N, max=M
|
|
783
|
+
// ..M → min=1, max=M (default min)
|
|
784
|
+
// Forms REJECTED:
|
|
785
|
+
// (empty) → bare `*` — unbounded → unsupported_clause
|
|
786
|
+
// N.. → unbounded max → unsupported_clause
|
|
787
|
+
// .. → unbounded max → unsupported_clause
|
|
788
|
+
const tok = this.peek();
|
|
789
|
+
const hasLower = tok.type === "NUMBER";
|
|
790
|
+
let min = 1;
|
|
791
|
+
let max = Infinity;
|
|
792
|
+
if (hasLower) {
|
|
793
|
+
const n = this.parseNonNegativeInt("the minimum hop count");
|
|
794
|
+
min = n;
|
|
795
|
+
max = n; // `*N` form: exactly N
|
|
796
|
+
}
|
|
797
|
+
if (this.peek().type === "DOTDOT") {
|
|
798
|
+
this.advance();
|
|
799
|
+
// After `..`, a NUMBER is required (we don't accept unbounded max).
|
|
800
|
+
if (this.peek().type === "NUMBER") {
|
|
801
|
+
max = this.parseNonNegativeInt("the maximum hop count");
|
|
802
|
+
} else {
|
|
803
|
+
throw parseError(
|
|
804
|
+
this.peek().pos,
|
|
805
|
+
"Unbounded variable-length `*..` is not supported. Specify a maximum, e.g. `*1..3`. The read subset rejects unbounded traversal.",
|
|
806
|
+
"unsupported_clause",
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
} else if (!hasLower) {
|
|
810
|
+
// Bare `*` with nothing after it.
|
|
811
|
+
throw parseError(
|
|
812
|
+
tok.pos,
|
|
813
|
+
"Unbounded variable-length `*` is not supported. Specify a range, e.g. `*1..3` or `*2`. The read subset rejects unbounded traversal.",
|
|
814
|
+
"unsupported_clause",
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
if (min < 0 || max < 0) {
|
|
818
|
+
throw parseError(tok.pos, "Hop counts must be non-negative integers.");
|
|
819
|
+
}
|
|
820
|
+
return { min, max };
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// ── dash-run consumption ───────────────────────────────────────────────
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Left-side dash-run result. `"incoming"` = `<-` arrow; `"dash"` = bare
|
|
827
|
+
* `-`/`--` (direction-neutral — the OTHER side's arrow wins, or undirected
|
|
828
|
+
* if neither side has an arrow); `"none"` = nothing present.
|
|
829
|
+
*/
|
|
830
|
+
private consumeLeftDashRun(): "incoming" | "dash" | "none" {
|
|
831
|
+
const t = this.peek();
|
|
832
|
+
if (t.type === "ARROW_L") {
|
|
833
|
+
this.advance();
|
|
834
|
+
return "incoming";
|
|
835
|
+
}
|
|
836
|
+
if (t.type === "DASHDASH" || t.type === "DASH") {
|
|
837
|
+
this.advance();
|
|
838
|
+
return "dash";
|
|
839
|
+
}
|
|
840
|
+
return "none";
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Right-side dash-run result. `"outgoing"` = `->`/`-->` arrow; `"dash"`
|
|
845
|
+
* = bare `-`/`--` (direction-neutral); `"none"` = nothing present.
|
|
846
|
+
*/
|
|
847
|
+
private consumeRightDashRun(): "outgoing" | "dash" | "none" {
|
|
848
|
+
const t = this.peek();
|
|
849
|
+
if (t.type === "ARROW_R") {
|
|
850
|
+
this.advance();
|
|
851
|
+
return "outgoing";
|
|
852
|
+
}
|
|
853
|
+
if (t.type === "DASHDASH" || t.type === "DASH") {
|
|
854
|
+
this.advance();
|
|
855
|
+
return "dash";
|
|
856
|
+
}
|
|
857
|
+
return "none";
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ── WHERE ──────────────────────────────────────────────────────────────
|
|
861
|
+
|
|
862
|
+
private parseWhere(): WhereClause {
|
|
863
|
+
// Flat OR-of-AND: split on OR, each side is AND-joined terms.
|
|
864
|
+
const orGroups: WhereTerm[][] = [];
|
|
865
|
+
orGroups.push(this.parseAndGroup());
|
|
866
|
+
while (this.consumeKeyword("or")) {
|
|
867
|
+
orGroups.push(this.parseAndGroup());
|
|
868
|
+
}
|
|
869
|
+
return { orGroups };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
private parseAndGroup(): WhereTerm[] {
|
|
873
|
+
const terms: WhereTerm[] = [this.parseComparison()];
|
|
874
|
+
while (this.consumeKeyword("and")) {
|
|
875
|
+
terms.push(this.parseComparison());
|
|
876
|
+
}
|
|
877
|
+
return terms;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private parseComparison(): Comparison {
|
|
881
|
+
const varTok = this.expect(
|
|
882
|
+
"WORD",
|
|
883
|
+
"a variable in WHERE (e.g. `a.name = \"foo\"`)",
|
|
884
|
+
);
|
|
885
|
+
if (this.isKeyword(varTok.text)) {
|
|
886
|
+
throw parseError(
|
|
887
|
+
varTok.pos,
|
|
888
|
+
`Expected a variable name in WHERE but found keyword ${JSON.stringify(varTok.text)}.`,
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
this.expect("DOT", "`.` between variable and property in WHERE");
|
|
892
|
+
const keyTok = this.expect("WORD", "a property name after `var.`");
|
|
893
|
+
const op = this.parseOp();
|
|
894
|
+
const value = this.parseLiteral();
|
|
895
|
+
return { varName: varTok.text, key: keyTok.text, op, value };
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
private parseOp(): Comparison["op"] {
|
|
899
|
+
const t = this.peek();
|
|
900
|
+
switch (t.type) {
|
|
901
|
+
case "EQ":
|
|
902
|
+
this.advance();
|
|
903
|
+
return "=";
|
|
904
|
+
case "NE":
|
|
905
|
+
this.advance();
|
|
906
|
+
return "<>";
|
|
907
|
+
case "GT":
|
|
908
|
+
this.advance();
|
|
909
|
+
return ">";
|
|
910
|
+
case "LT":
|
|
911
|
+
this.advance();
|
|
912
|
+
return "<";
|
|
913
|
+
case "GE":
|
|
914
|
+
this.advance();
|
|
915
|
+
return ">=";
|
|
916
|
+
case "LE":
|
|
917
|
+
this.advance();
|
|
918
|
+
return "<=";
|
|
919
|
+
default:
|
|
920
|
+
throw parseError(
|
|
921
|
+
t.pos,
|
|
922
|
+
"Expected a comparison operator (=, <>, !=, >, <, >=, <=) in WHERE.",
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// ── RETURN ─────────────────────────────────────────────────────────────
|
|
928
|
+
|
|
929
|
+
private parseReturn(): ReturnItem[] {
|
|
930
|
+
// Reject `RETURN *` explicitly — it's outside the subset.
|
|
931
|
+
if (this.peek().type === "STAR") {
|
|
932
|
+
throw parseError(
|
|
933
|
+
this.peek().pos,
|
|
934
|
+
"`RETURN *` is outside the read subset. List the items to project explicitly, e.g. `RETURN a, b.name`.",
|
|
935
|
+
"unsupported_clause",
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
const items: ReturnItem[] = [this.parseReturnItem()];
|
|
939
|
+
while (this.peek().type === "COMMA") {
|
|
940
|
+
this.advance();
|
|
941
|
+
items.push(this.parseReturnItem());
|
|
942
|
+
}
|
|
943
|
+
if (items.length === 0) {
|
|
944
|
+
// parseReturnItem always produces one; defensive.
|
|
945
|
+
throw parseError(this.peek().pos, "RETURN requires at least one item.");
|
|
946
|
+
}
|
|
947
|
+
return items;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
private parseReturnItem(): ReturnItem {
|
|
951
|
+
const varTok = this.expect(
|
|
952
|
+
"WORD",
|
|
953
|
+
"a variable (or var.prop) in RETURN",
|
|
954
|
+
);
|
|
955
|
+
if (this.isKeyword(varTok.text)) {
|
|
956
|
+
throw parseError(
|
|
957
|
+
varTok.pos,
|
|
958
|
+
`Expected a variable name in RETURN but found keyword ${JSON.stringify(varTok.text)}.`,
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
if (this.peek().type === "DOT") {
|
|
962
|
+
this.advance();
|
|
963
|
+
const keyTok = this.expect("WORD", "a property name after `var.`");
|
|
964
|
+
return { kind: "prop", varName: varTok.text, key: keyTok.text };
|
|
965
|
+
}
|
|
966
|
+
return { kind: "var", varName: varTok.text };
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// ── numeric helpers ────────────────────────────────────────────────────
|
|
970
|
+
|
|
971
|
+
private parseNonNegativeInt(label: string): number {
|
|
972
|
+
const tok = this.peek();
|
|
973
|
+
if (tok.type === "NUMBER") {
|
|
974
|
+
const n = Number(tok.text);
|
|
975
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
976
|
+
throw parseError(
|
|
977
|
+
tok.pos,
|
|
978
|
+
`${label} must be a non-negative integer; got ${JSON.stringify(tok.text)}.`,
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
this.advance();
|
|
982
|
+
return n;
|
|
983
|
+
}
|
|
984
|
+
throw parseError(tok.pos, `Expected a non-negative integer for ${label}.`);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// ── literals ───────────────────────────────────────────────────────────
|
|
988
|
+
|
|
989
|
+
private parseLiteral(): CypherScalar {
|
|
990
|
+
const tok = this.peek();
|
|
991
|
+
// Optional leading `-` for negative numbers.
|
|
992
|
+
let negate = false;
|
|
993
|
+
if (tok.type === "DASH") {
|
|
994
|
+
negate = true;
|
|
995
|
+
this.advance();
|
|
996
|
+
}
|
|
997
|
+
const t = negate ? this.peek() : tok;
|
|
998
|
+
if (t.type === "STRING") {
|
|
999
|
+
if (negate) {
|
|
1000
|
+
throw parseError(t.pos, "Cannot negate a string literal.");
|
|
1001
|
+
}
|
|
1002
|
+
this.advance();
|
|
1003
|
+
return t.text;
|
|
1004
|
+
}
|
|
1005
|
+
if (t.type === "NUMBER") {
|
|
1006
|
+
this.advance();
|
|
1007
|
+
const n = Number(t.text);
|
|
1008
|
+
return negate ? -n : n;
|
|
1009
|
+
}
|
|
1010
|
+
if (t.type === "WORD") {
|
|
1011
|
+
if (t.text.toLowerCase() === "true") {
|
|
1012
|
+
if (negate) {
|
|
1013
|
+
throw parseError(t.pos, "Cannot negate `true`.");
|
|
1014
|
+
}
|
|
1015
|
+
this.advance();
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
1018
|
+
if (t.text.toLowerCase() === "false") {
|
|
1019
|
+
if (negate) {
|
|
1020
|
+
throw parseError(t.pos, "Cannot negate `false`.");
|
|
1021
|
+
}
|
|
1022
|
+
this.advance();
|
|
1023
|
+
return false;
|
|
1024
|
+
}
|
|
1025
|
+
if (t.text.toLowerCase() === "null") {
|
|
1026
|
+
if (negate) {
|
|
1027
|
+
throw parseError(t.pos, "Cannot negate `null`.");
|
|
1028
|
+
}
|
|
1029
|
+
this.advance();
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
// Any other WORD here is a bare identifier where a literal was
|
|
1033
|
+
// expected — almost certainly a typo or an unsupported construct.
|
|
1034
|
+
// If it's a write/outside keyword, surface that message.
|
|
1035
|
+
const outside = WRITE_OR_OUTSIDE_CLAUSES[t.text.toLowerCase()];
|
|
1036
|
+
if (outside) {
|
|
1037
|
+
throw parseError(t.pos, outside);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
throw parseError(
|
|
1041
|
+
tok.pos,
|
|
1042
|
+
"Expected a literal value (string, number, true, false, or null).",
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// ── AST validation ─────────────────────────────────────────────────────
|
|
1047
|
+
|
|
1048
|
+
private validateAst(
|
|
1049
|
+
match: MatchClause,
|
|
1050
|
+
where: WhereClause | undefined,
|
|
1051
|
+
returnItems: ReturnItem[],
|
|
1052
|
+
): void {
|
|
1053
|
+
// Collect bound variable names (anonymous nodes contribute nothing).
|
|
1054
|
+
const bound = new Set<string>();
|
|
1055
|
+
for (const n of match.nodes) {
|
|
1056
|
+
if (n.varName) bound.add(n.varName);
|
|
1057
|
+
}
|
|
1058
|
+
// Validate labels.
|
|
1059
|
+
for (const n of match.nodes) {
|
|
1060
|
+
if (n.label !== undefined && !(n.label in CYPHER_LABEL_TO_DB_LABEL)) {
|
|
1061
|
+
throw parseError(
|
|
1062
|
+
0,
|
|
1063
|
+
`Unknown label ${JSON.stringify(n.label)}. Valid labels: ${VALID_CYPHER_LABELS.join(", ")}.`,
|
|
1064
|
+
"unknown_label",
|
|
1065
|
+
[...VALID_CYPHER_LABELS],
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
// Validate WHERE variable references.
|
|
1070
|
+
if (where) {
|
|
1071
|
+
for (const group of where.orGroups) {
|
|
1072
|
+
for (const term of group) {
|
|
1073
|
+
if (!bound.has(term.varName)) {
|
|
1074
|
+
throw parseError(
|
|
1075
|
+
0,
|
|
1076
|
+
`WHERE references variable ${JSON.stringify(term.varName)} which is not bound by MATCH. Bound variables: ${[...bound].join(", ") || "(none)"}.`,
|
|
1077
|
+
"unbound_variable",
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
// Validate RETURN variable references.
|
|
1084
|
+
for (const item of returnItems) {
|
|
1085
|
+
if (!bound.has(item.varName)) {
|
|
1086
|
+
throw parseError(
|
|
1087
|
+
0,
|
|
1088
|
+
`RETURN references variable ${JSON.stringify(item.varName)} which is not bound by MATCH. Bound variables: ${[...bound].join(", ") || "(none)"}.`,
|
|
1089
|
+
"unbound_variable",
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// ── token helpers ──────────────────────────────────────────────────────
|
|
1096
|
+
|
|
1097
|
+
private peek(): Token {
|
|
1098
|
+
return this.tokens[this.i]!;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
private advance(): Token {
|
|
1102
|
+
const t = this.tokens[this.i]!;
|
|
1103
|
+
if (this.i < this.tokens.length - 1) this.i += 1;
|
|
1104
|
+
return t;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
private expect(type: TokenType, what: string): Token {
|
|
1108
|
+
const t = this.peek();
|
|
1109
|
+
if (t.type !== type) {
|
|
1110
|
+
throw parseError(
|
|
1111
|
+
t.pos,
|
|
1112
|
+
`Expected ${what} but found ${describeToken(t)}. Supported grammar: MATCH (a:Label {p: "v"})-[:TYPE*1..3]->(b) WHERE ... RETURN ... LIMIT n.`,
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
return this.advance();
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
private expectKeyword(kw: string): Token {
|
|
1119
|
+
const t = this.peek();
|
|
1120
|
+
if (t.type === "WORD" && t.text.toLowerCase() === kw) {
|
|
1121
|
+
return this.advance();
|
|
1122
|
+
}
|
|
1123
|
+
// Helpful: if it's a write/outside clause, lead with that message.
|
|
1124
|
+
if (t.type === "WORD") {
|
|
1125
|
+
const outside = WRITE_OR_OUTSIDE_CLAUSES[t.text.toLowerCase()];
|
|
1126
|
+
if (outside) {
|
|
1127
|
+
throw parseError(t.pos, outside, "unsupported_clause");
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
throw parseError(
|
|
1131
|
+
t.pos,
|
|
1132
|
+
`Expected keyword ${kw.toUpperCase()} but found ${describeToken(t)}. Queries must start with MATCH and use only MATCH/WHERE/RETURN/LIMIT.`,
|
|
1133
|
+
"parse_error",
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
private consumeKeyword(kw: string): boolean {
|
|
1138
|
+
const t = this.peek();
|
|
1139
|
+
if (t.type === "WORD" && t.text.toLowerCase() === kw) {
|
|
1140
|
+
this.advance();
|
|
1141
|
+
return true;
|
|
1142
|
+
}
|
|
1143
|
+
return false;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
private isKeyword(text: string): boolean {
|
|
1147
|
+
return KEYWORDS[text.toLowerCase()] === true;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
private unsupportedAfter(tok: Token): CypherParseError {
|
|
1151
|
+
const outside = WRITE_OR_OUTSIDE_CLAUSES[tok.text.toLowerCase()];
|
|
1152
|
+
if (outside) {
|
|
1153
|
+
return parseError(tok.pos, outside, "unsupported_clause");
|
|
1154
|
+
}
|
|
1155
|
+
return parseError(
|
|
1156
|
+
tok.pos,
|
|
1157
|
+
`Unexpected token ${describeToken(tok)} after the query. Only MATCH/WHERE/RETURN/LIMIT are supported.`,
|
|
1158
|
+
"parse_error",
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function resolveDirection(
|
|
1164
|
+
left: "incoming" | "dash" | "none",
|
|
1165
|
+
right: "outgoing" | "dash" | "none",
|
|
1166
|
+
): RelDirection {
|
|
1167
|
+
// Arrows on each side carry direction; bare dashes are direction-neutral.
|
|
1168
|
+
// -[...]-> : left="dash", right="outgoing" → outgoing
|
|
1169
|
+
// <-[...]- : left="incoming", right="dash" → incoming
|
|
1170
|
+
// -[...]- : left="dash", right="dash" → both (undirected)
|
|
1171
|
+
// <-[...]-> : left="incoming", right="outgoing" → conflict (reject)
|
|
1172
|
+
const leftArrow = left === "incoming";
|
|
1173
|
+
const rightArrow = right === "outgoing";
|
|
1174
|
+
if (leftArrow && rightArrow) {
|
|
1175
|
+
throw parseError(
|
|
1176
|
+
0,
|
|
1177
|
+
"Conflicting relationship direction (e.g. `<-[...]->`). Use a consistent direction.",
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
if (leftArrow) return "incoming";
|
|
1181
|
+
if (rightArrow) return "outgoing";
|
|
1182
|
+
return "both";
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1186
|
+
// Errors.
|
|
1187
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1188
|
+
|
|
1189
|
+
class CypherParseError extends Error {
|
|
1190
|
+
readonly code: CypherFailureCode;
|
|
1191
|
+
readonly pos: number;
|
|
1192
|
+
readonly validLabels?: readonly string[];
|
|
1193
|
+
constructor(
|
|
1194
|
+
pos: number,
|
|
1195
|
+
message: string,
|
|
1196
|
+
code: CypherFailureCode = "parse_error",
|
|
1197
|
+
validLabels?: readonly string[],
|
|
1198
|
+
) {
|
|
1199
|
+
super(message);
|
|
1200
|
+
this.name = "CypherParseError";
|
|
1201
|
+
this.pos = pos;
|
|
1202
|
+
this.code = code;
|
|
1203
|
+
this.validLabels = validLabels;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function parseError(
|
|
1208
|
+
pos: number,
|
|
1209
|
+
message: string,
|
|
1210
|
+
code: CypherFailureCode = "parse_error",
|
|
1211
|
+
validLabels?: readonly string[],
|
|
1212
|
+
): CypherParseError {
|
|
1213
|
+
return new CypherParseError(pos, message, code, validLabels);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function describeToken(t: Token): string {
|
|
1217
|
+
if (t.type === "EOF") return "end of input";
|
|
1218
|
+
if (t.type === "WORD") return `identifier ${JSON.stringify(t.text)}`;
|
|
1219
|
+
if (t.type === "STRING") return `string ${JSON.stringify(t.text)}`;
|
|
1220
|
+
if (t.type === "NUMBER") return `number ${t.text}`;
|
|
1221
|
+
return JSON.stringify(t.text);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1225
|
+
// Public parse API.
|
|
1226
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1227
|
+
|
|
1228
|
+
export type CypherParseResult =
|
|
1229
|
+
| { ok: true; ast: CypherAst }
|
|
1230
|
+
| CypherFailure;
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Parse a Cypher query string into an AST without executing it. Use this
|
|
1234
|
+
* to validate query shape (e.g. at a tool boundary) before opening a
|
|
1235
|
+
* store. The AST is an opaque internal type; callers should treat it as
|
|
1236
|
+
* a handle to pass to {@link executeAst}.
|
|
1237
|
+
*/
|
|
1238
|
+
export function parseCypher(query: string): CypherParseResult {
|
|
1239
|
+
if (typeof query !== "string") {
|
|
1240
|
+
return {
|
|
1241
|
+
ok: false,
|
|
1242
|
+
code: "invalid_query",
|
|
1243
|
+
message: "Cypher query must be a string.",
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
if (query.length === 0 || query.trim().length === 0) {
|
|
1247
|
+
return {
|
|
1248
|
+
ok: false,
|
|
1249
|
+
code: "parse_error",
|
|
1250
|
+
message: "Empty query. Supported grammar: MATCH (a:Label {p: \"v\"})-[:TYPE*1..3]->(b) WHERE ... RETURN ... LIMIT n.",
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
try {
|
|
1254
|
+
const tokens = new Tokenizer(query).tokenize();
|
|
1255
|
+
const ast = new Parser(tokens).parse();
|
|
1256
|
+
return { ok: true, ast };
|
|
1257
|
+
} catch (e) {
|
|
1258
|
+
if (e instanceof CypherParseError) {
|
|
1259
|
+
return {
|
|
1260
|
+
ok: false,
|
|
1261
|
+
code: e.code,
|
|
1262
|
+
message: e.message,
|
|
1263
|
+
...(e.validLabels ? { validLabels: e.validLabels } : {}),
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
return {
|
|
1267
|
+
ok: false,
|
|
1268
|
+
code: "parse_error",
|
|
1269
|
+
message: e instanceof Error ? e.message : String(e),
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1275
|
+
// Executor — compiles the AST to searchGraph / traverse calls.
|
|
1276
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1277
|
+
|
|
1278
|
+
/** Property-access aliases — both camelCase and snake_case work. */
|
|
1279
|
+
const PROP_ALIASES: Record<string, keyof CypherNodeValue> = {
|
|
1280
|
+
name: "name",
|
|
1281
|
+
qualifiedname: "qualifiedName",
|
|
1282
|
+
qualified_name: "qualifiedName",
|
|
1283
|
+
label: "label",
|
|
1284
|
+
kind: "label",
|
|
1285
|
+
filepath: "filePath",
|
|
1286
|
+
file_path: "filePath",
|
|
1287
|
+
nodeid: "nodeId",
|
|
1288
|
+
node_id: "nodeId",
|
|
1289
|
+
id: "nodeId",
|
|
1290
|
+
};
|
|
1291
|
+
|
|
1292
|
+
function nodeToValue(hit: SearchHit | TraverseHit | TraversePathHit): CypherNodeValue {
|
|
1293
|
+
return {
|
|
1294
|
+
nodeId: hit.nodeId,
|
|
1295
|
+
qualifiedName: hit.qualifiedName,
|
|
1296
|
+
name: hit.name,
|
|
1297
|
+
label: hit.label,
|
|
1298
|
+
filePath: hit.filePath,
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function readProperty(node: CypherNodeValue, key: string): CypherScalar {
|
|
1303
|
+
const alias = PROP_ALIASES[key.toLowerCase()];
|
|
1304
|
+
if (!alias) {
|
|
1305
|
+
// Unknown property → return null rather than throw, so a query like
|
|
1306
|
+
// `RETURN a.confidence` on a node (which has no confidence) returns
|
|
1307
|
+
// null instead of failing the whole query. Matches Cypher semantics
|
|
1308
|
+
// for missing properties.
|
|
1309
|
+
return null;
|
|
1310
|
+
}
|
|
1311
|
+
return node[alias];
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function compareValues(a: CypherScalar, op: Comparison["op"], b: CypherScalar): boolean {
|
|
1315
|
+
// Type-coercion rules (deliberately simple, documented):
|
|
1316
|
+
// - number op number → numeric compare
|
|
1317
|
+
// - string op string → lexical compare
|
|
1318
|
+
// - bool op bool → for = / <> only
|
|
1319
|
+
// - null involved → false for ordering ops; =/<> follow SQL three-valued
|
|
1320
|
+
// logic (null = anything → unknown → false here; null <> anything → false)
|
|
1321
|
+
// - mixed types for ordering → false (no coercion)
|
|
1322
|
+
if (a === null || b === null) {
|
|
1323
|
+
if (op === "=" || op === "<>" || op === "!=") {
|
|
1324
|
+
// SQL: NULL = x and NULL <> x are both UNKNOWN → false.
|
|
1325
|
+
return false;
|
|
1326
|
+
}
|
|
1327
|
+
return false;
|
|
1328
|
+
}
|
|
1329
|
+
if (op === "=") return a === b;
|
|
1330
|
+
if (op === "<>" || op === "!=") return a !== b;
|
|
1331
|
+
if (typeof a !== typeof b) return false;
|
|
1332
|
+
if (typeof a === "boolean") {
|
|
1333
|
+
// Booleans only support = / <> (handled above).
|
|
1334
|
+
return false;
|
|
1335
|
+
}
|
|
1336
|
+
if (op === ">") return (a as number | string) > (b as number | string);
|
|
1337
|
+
if (op === "<") return (a as number | string) < (b as number | string);
|
|
1338
|
+
if (op === ">=") return (a as number | string) >= (b as number | string);
|
|
1339
|
+
if (op === "<=") return (a as number | string) <= (b as number | string);
|
|
1340
|
+
return false;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* A binding tracks both the named variables (for WHERE / RETURN) AND the
|
|
1345
|
+
* most-recently-resolved node by position (`lastNode`). The positional
|
|
1346
|
+
* cursor is what makes anonymous nodes work: `MATCH ()-[:CALLS]->(b)`
|
|
1347
|
+
* starts from every node (anonymous first node) and traverses forward,
|
|
1348
|
+
* and `MATCH (a)-[:CALLS]->()-[:CALLS]->(c)` flows through the anonymous
|
|
1349
|
+
* middle node without dropping the path (cursor Bugbot: 'Anonymous nodes
|
|
1350
|
+
* break path expansion').
|
|
1351
|
+
*/
|
|
1352
|
+
interface Binding {
|
|
1353
|
+
varByName: Map<string, CypherNodeValue>;
|
|
1354
|
+
lastNode: CypherNodeValue;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Push ONE `name` / `filePath` equality filter down to the structured
|
|
1359
|
+
* `searchGraph` filters so the candidate set is narrowed by the index
|
|
1360
|
+
* BEFORE the 1000-row cap applies. A specific name like `"runServer"`
|
|
1361
|
+
* narrows from the whole graph to a handful of rows, so a low-degree
|
|
1362
|
+
* node with an exact name match is no longer truncated out (cursor
|
|
1363
|
+
* Bugbot: 'Start search truncates before filters').
|
|
1364
|
+
*
|
|
1365
|
+
* Only LITERAL values are pushed: a `%`/`_` in the value would act as a
|
|
1366
|
+
* LIKE wildcard under searchGraph's `LIKE ... COLLATE NOCASE`, ballooning
|
|
1367
|
+
* the candidate set (e.g. `"foo_bar"` matching `fooXbar`). Such values
|
|
1368
|
+
* fall back to the capped label scan + exact post-filter instead
|
|
1369
|
+
* (chatgpt-codex-connector: 'Escape LIKE wildcards before start-node
|
|
1370
|
+
* pushdown'). The first writer wins (inline properties take priority
|
|
1371
|
+
* over a WHERE term) so two constraints on the same field don't clobber
|
|
1372
|
+
* each other. The post-filter ({@link matchesNodePattern} /
|
|
1373
|
+
* {@link matchesWhere}) always enforces exact case-sensitive equality,
|
|
1374
|
+
* so this narrowing never loses a valid row.
|
|
1375
|
+
*/
|
|
1376
|
+
function pushFilterToSearch(
|
|
1377
|
+
query: { namePattern?: string; filePattern?: string },
|
|
1378
|
+
key: string,
|
|
1379
|
+
value: CypherScalar,
|
|
1380
|
+
): void {
|
|
1381
|
+
if (typeof value !== "string") return;
|
|
1382
|
+
// Skip LIKE metacharacters so the pushed pattern stays literal-exact.
|
|
1383
|
+
if (value.includes("%") || value.includes("_")) return;
|
|
1384
|
+
const k = key.toLowerCase();
|
|
1385
|
+
if (k === "name" && query.namePattern === undefined) {
|
|
1386
|
+
query.namePattern = value;
|
|
1387
|
+
} else if (
|
|
1388
|
+
(k === "filepath" || k === "file_path") &&
|
|
1389
|
+
query.filePattern === undefined
|
|
1390
|
+
) {
|
|
1391
|
+
query.filePattern = value;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* Execute a parsed AST against a store. Exposed so callers that already
|
|
1397
|
+
* hold an AST (e.g. a cached plan) can skip re-parsing.
|
|
1398
|
+
*/
|
|
1399
|
+
export function executeAst(store: GraphStore, ast: CypherAst): CypherResult {
|
|
1400
|
+
// 1. Resolve the FIRST node pattern via searchGraph. The label + any
|
|
1401
|
+
// inline `name`/`filePath` property filters are pushed down so the
|
|
1402
|
+
// candidate set is narrowed by the index before the 1000-row cap;
|
|
1403
|
+
// a supported first-variable WHERE equality term (`f.name = "x"`) is
|
|
1404
|
+
// pushed down too when WHERE is a single conjunction (no top-level
|
|
1405
|
+
// OR), so `MATCH (f) WHERE f.name = "rare"` is narrowed before the
|
|
1406
|
+
// cap rather than after (chatgpt-codex-connector: 'Push down first-
|
|
1407
|
+
// node WHERE filters before capping'). Remaining inline properties +
|
|
1408
|
+
// the full WHERE are applied as JS post-filters. A closed store
|
|
1409
|
+
// surfaces as `{ ok: false, code: "store_closed" }` from searchGraph
|
|
1410
|
+
// itself (we don't reach into the private `closed` flag).
|
|
1411
|
+
const firstNode = ast.match.nodes[0]!;
|
|
1412
|
+
const searchQuery: {
|
|
1413
|
+
label?: string;
|
|
1414
|
+
namePattern?: string;
|
|
1415
|
+
filePattern?: string;
|
|
1416
|
+
limit: number;
|
|
1417
|
+
} = { limit: 1000 };
|
|
1418
|
+
if (firstNode.label !== undefined) {
|
|
1419
|
+
searchQuery.label = CYPHER_LABEL_TO_DB_LABEL[firstNode.label]!;
|
|
1420
|
+
}
|
|
1421
|
+
for (const { key, value } of firstNode.properties) {
|
|
1422
|
+
pushFilterToSearch(searchQuery, key, value);
|
|
1423
|
+
}
|
|
1424
|
+
// Single OR-group ⇒ pure conjunction ⇒ every term is a necessary
|
|
1425
|
+
// condition, so pushing a first-var `=` term down only narrows. With OR
|
|
1426
|
+
// (multiple groups) we push nothing — a term true on one branch is not
|
|
1427
|
+
// necessary overall, and pushing it would drop the other branch's rows.
|
|
1428
|
+
if (ast.where && ast.where.orGroups.length === 1 && firstNode.varName) {
|
|
1429
|
+
for (const term of ast.where.orGroups[0]!) {
|
|
1430
|
+
if (term.varName === firstNode.varName && term.op === "=") {
|
|
1431
|
+
pushFilterToSearch(searchQuery, term.key, term.value);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
const search = store.searchGraph(searchQuery);
|
|
1436
|
+
if (!search.ok) {
|
|
1437
|
+
return storeFailureToCypher(search);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
let bindings: Binding[] = search.hits
|
|
1441
|
+
.map((hit) => nodeToValue(hit))
|
|
1442
|
+
.filter((node) => matchesNodePattern(node, firstNode))
|
|
1443
|
+
.map((node) => {
|
|
1444
|
+
const varByName = new Map<string, CypherNodeValue>();
|
|
1445
|
+
if (firstNode.varName) varByName.set(firstNode.varName, node);
|
|
1446
|
+
return { varByName, lastNode: node };
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
// OR-across every variable-length expansion: any traversePaths cap hit
|
|
1450
|
+
// makes the final result a partial endpoint set (issue #1650).
|
|
1451
|
+
let truncated = false;
|
|
1452
|
+
|
|
1453
|
+
// 2. Walk the remaining (rel, node) pairs, expanding each binding via
|
|
1454
|
+
// traverse. The traverse start is the binding's POSITIONAL lastNode,
|
|
1455
|
+
// not a named variable — so anonymous nodes anywhere in the path
|
|
1456
|
+
// still pass the cursor forward.
|
|
1457
|
+
for (let idx = 0; idx < ast.match.rels.length; idx += 1) {
|
|
1458
|
+
const rel = ast.match.rels[idx]!;
|
|
1459
|
+
const nodePattern = ast.match.nodes[idx + 1]!;
|
|
1460
|
+
const nextBindings: Binding[] = [];
|
|
1461
|
+
// Surface an oversized variable-length depth as a query-level failure
|
|
1462
|
+
// instead of letting the store cap silently drop results: the store
|
|
1463
|
+
// rejects maxHops > MAX_TRAVERSE_PATHS_HOPS, but this loop's invalid_query
|
|
1464
|
+
// skip would otherwise hide it and return an empty success
|
|
1465
|
+
// (cursor Bugbot: 'Hop cap yields silent drops'; chatgpt-codex-
|
|
1466
|
+
// connector P2: 'Propagate oversized variable-length').
|
|
1467
|
+
if (rel.isVarLength && rel.maxHops > MAX_TRAVERSE_PATHS_HOPS) {
|
|
1468
|
+
const range =
|
|
1469
|
+
rel.minHops === rel.maxHops
|
|
1470
|
+
? `*${rel.maxHops}`
|
|
1471
|
+
: `*${rel.minHops}..${rel.maxHops}`;
|
|
1472
|
+
return {
|
|
1473
|
+
ok: false,
|
|
1474
|
+
code: "invalid_query",
|
|
1475
|
+
message: `Variable-length ${range} exceeds the maximum supported traversal depth (${MAX_TRAVERSE_PATHS_HOPS}). Narrow the range.`,
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
for (const binding of bindings) {
|
|
1479
|
+
// Collect candidate endpoint nodes for THIS binding + rel.
|
|
1480
|
+
const candidates: CypherNodeValue[] = [];
|
|
1481
|
+
if (rel.isVarLength) {
|
|
1482
|
+
// Variable-length (`*M..N` / `*N`) compiles to the path-enumerating
|
|
1483
|
+
// primitive (issue #1650) so an exact `*N` honors concrete length-N
|
|
1484
|
+
// paths, not just BFS shortest-depth reachability. A node reachable
|
|
1485
|
+
// at both a shorter and a length-N path is now returned for the
|
|
1486
|
+
// length-N path, fixing the dropped-endpoint bug.
|
|
1487
|
+
const tp = store.traversePaths({
|
|
1488
|
+
start: binding.lastNode.nodeId,
|
|
1489
|
+
direction: rel.direction,
|
|
1490
|
+
...(rel.types.length > 0 ? { edgeTypes: rel.types } : {}),
|
|
1491
|
+
// Push minHops into the store so its maxPaths cap counts only
|
|
1492
|
+
// in-range paths, not the shorter prefixes an exact *N must walk
|
|
1493
|
+
// (cursor Bugbot: 'Path cap ignores hop minimum').
|
|
1494
|
+
minHops: Math.max(1, rel.minHops),
|
|
1495
|
+
maxHops: rel.maxHops,
|
|
1496
|
+
});
|
|
1497
|
+
if (!tp.ok) {
|
|
1498
|
+
// unknown_start can happen if the node vanished between search
|
|
1499
|
+
// and traverse -- skip this binding rather than fail the whole
|
|
1500
|
+
// query. Genuine db errors propagate.
|
|
1501
|
+
if (
|
|
1502
|
+
tp.code === "unknown_start" ||
|
|
1503
|
+
tp.code === "ambiguous_start" ||
|
|
1504
|
+
tp.code === "invalid_query"
|
|
1505
|
+
) {
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
return storeFailureToCypher(tp);
|
|
1509
|
+
}
|
|
1510
|
+
// Path enumeration may have stopped at the maxPaths cap -- surface
|
|
1511
|
+
// that so callers can detect an incomplete result instead of
|
|
1512
|
+
// silently omitting reachable nodes (cursor Bugbot: 'Ignores path
|
|
1513
|
+
// enumeration truncation').
|
|
1514
|
+
if (tp.truncated) truncated = true;
|
|
1515
|
+
// A `*0..N` bound includes the trivial length-0 path (the start
|
|
1516
|
+
// node itself) -- traversePaths only yields length >= 1 paths.
|
|
1517
|
+
if (rel.minHops === 0) candidates.push(binding.lastNode);
|
|
1518
|
+
// traversePaths already restricts emitted paths to
|
|
1519
|
+
// [max(1, minHops), maxHops], so no length filter is needed here.
|
|
1520
|
+
for (const hit of tp.hits) {
|
|
1521
|
+
candidates.push(nodeToValue(hit));
|
|
1522
|
+
}
|
|
1523
|
+
} else {
|
|
1524
|
+
// Fixed-length single hop (no `*`): BFS traverse is exact for
|
|
1525
|
+
// direct neighbors -- the original compile target, unchanged.
|
|
1526
|
+
const t = store.traverse({
|
|
1527
|
+
start: binding.lastNode.nodeId,
|
|
1528
|
+
direction: rel.direction,
|
|
1529
|
+
...(rel.types.length > 0 ? { edgeTypes: rel.types } : {}),
|
|
1530
|
+
maxDepth: rel.maxHops,
|
|
1531
|
+
});
|
|
1532
|
+
if (!t.ok) {
|
|
1533
|
+
if (
|
|
1534
|
+
t.code === "unknown_start" ||
|
|
1535
|
+
t.code === "ambiguous_start" ||
|
|
1536
|
+
t.code === "invalid_query"
|
|
1537
|
+
) {
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
return storeFailureToCypher(t);
|
|
1541
|
+
}
|
|
1542
|
+
// Depth filter: traverse's depth is inclusive; the relationship's
|
|
1543
|
+
// minHops/maxHops are inclusive bounds (depth in [minHops, maxHops]).
|
|
1544
|
+
for (const hit of t.hits) {
|
|
1545
|
+
if (hit.depth < rel.minHops || hit.depth > rel.maxHops) continue;
|
|
1546
|
+
candidates.push(nodeToValue(hit));
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
// Shared: dedupe by node id (one binding per distinct endpoint),
|
|
1550
|
+
// apply the target node pattern, then bind. Deduping by node id
|
|
1551
|
+
// preserves the read-subset's reachability contract for `*1..N`
|
|
1552
|
+
// (one row per reachable node), even though var-length now
|
|
1553
|
+
// enumerates paths internally (issue #1650).
|
|
1554
|
+
const seen = new Set<string>();
|
|
1555
|
+
for (const node of candidates) {
|
|
1556
|
+
if (seen.has(node.nodeId)) continue;
|
|
1557
|
+
seen.add(node.nodeId);
|
|
1558
|
+
if (!matchesNodePattern(node, nodePattern)) continue;
|
|
1559
|
+
const varByName = new Map(binding.varByName);
|
|
1560
|
+
if (nodePattern.varName) {
|
|
1561
|
+
// If the var was already bound (re-binding in a path), require
|
|
1562
|
+
// it to be the SAME node (Cypher equality semantics). Skip
|
|
1563
|
+
// otherwise.
|
|
1564
|
+
const existing = varByName.get(nodePattern.varName);
|
|
1565
|
+
if (existing && existing.nodeId !== node.nodeId) continue;
|
|
1566
|
+
varByName.set(nodePattern.varName, node);
|
|
1567
|
+
}
|
|
1568
|
+
nextBindings.push({ varByName, lastNode: node });
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
bindings = nextBindings;
|
|
1572
|
+
if (bindings.length === 0) break;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// 3. Apply WHERE (resolves variables by name — anonymous nodes have none).
|
|
1576
|
+
if (ast.where) {
|
|
1577
|
+
bindings = bindings.filter((b) => matchesWhere(b.varByName, ast.where!));
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// 4. Apply LIMIT.
|
|
1581
|
+
if (ast.limit !== undefined) {
|
|
1582
|
+
bindings = bindings.slice(0, ast.limit);
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// 5. Project RETURN (resolves variables by name).
|
|
1586
|
+
const columns = ast.return.map((item) =>
|
|
1587
|
+
item.kind === "var" ? item.varName : `${item.varName}.${item.key}`,
|
|
1588
|
+
);
|
|
1589
|
+
const rows: CypherRow[] = bindings.map((b) => {
|
|
1590
|
+
const row: CypherRow = {};
|
|
1591
|
+
ast.return.forEach((item) => {
|
|
1592
|
+
const col = item.kind === "var" ? item.varName : `${item.varName}.${item.key}`;
|
|
1593
|
+
const node = b.varByName.get(item.varName);
|
|
1594
|
+
if (!node) {
|
|
1595
|
+
row[col] = null;
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
row[col] =
|
|
1599
|
+
item.kind === "var" ? node : readProperty(node, item.key);
|
|
1600
|
+
});
|
|
1601
|
+
return row;
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
return truncated
|
|
1605
|
+
? { ok: true, columns, rows, truncated: true }
|
|
1606
|
+
: { ok: true, columns, rows };
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function matchesNodePattern(node: CypherNodeValue, pattern: NodePattern): boolean {
|
|
1610
|
+
// Enforce the parsed `:Label`. The FIRST node's label is already pushed
|
|
1611
|
+
// into searchGraph's `label` filter, so this is a no-op for it; for
|
|
1612
|
+
// relationship TARGET nodes this is the only place the parsed label is
|
|
1613
|
+
// enforced (cursor Bugbot: 'Relationship node labels not enforced' —
|
|
1614
|
+
// without this, `MATCH (a:Function)-[:CALLS]->(b:Type)` returned
|
|
1615
|
+
// Function nodes for `b`). `pattern.label` is validated at parse time,
|
|
1616
|
+
// so the mapped db label always exists when set.
|
|
1617
|
+
if (pattern.label !== undefined) {
|
|
1618
|
+
const dbLabel = CYPHER_LABEL_TO_DB_LABEL[pattern.label];
|
|
1619
|
+
if (dbLabel !== undefined && node.label !== dbLabel) return false;
|
|
1620
|
+
}
|
|
1621
|
+
for (const { key, value } of pattern.properties) {
|
|
1622
|
+
const actual = readProperty(node, key);
|
|
1623
|
+
if (!compareValues(actual, "=", value)) return false;
|
|
1624
|
+
}
|
|
1625
|
+
return true;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function matchesWhere(
|
|
1629
|
+
varByName: Map<string, CypherNodeValue>,
|
|
1630
|
+
where: WhereClause,
|
|
1631
|
+
): boolean {
|
|
1632
|
+
// OR-of-AND.
|
|
1633
|
+
return where.orGroups.some((group) =>
|
|
1634
|
+
group.every((term) => {
|
|
1635
|
+
const node = varByName.get(term.varName);
|
|
1636
|
+
if (!node) return false;
|
|
1637
|
+
const actual = readProperty(node, term.key);
|
|
1638
|
+
return compareValues(actual, term.op, term.value);
|
|
1639
|
+
}),
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
function storeFailureToCypher(f: {
|
|
1644
|
+
ok: false;
|
|
1645
|
+
code: string;
|
|
1646
|
+
}): CypherFailure {
|
|
1647
|
+
// Map store failure codes to Cypher failure codes (1:1 for the shared
|
|
1648
|
+
// suffix; store_closed is checked up-front in executeAst).
|
|
1649
|
+
switch (f.code) {
|
|
1650
|
+
case "db_locked":
|
|
1651
|
+
return { ok: false, code: "db_locked", message: "Database is locked." };
|
|
1652
|
+
case "db_corrupt":
|
|
1653
|
+
return { ok: false, code: "db_corrupt", message: "Database is corrupt." };
|
|
1654
|
+
case "store_closed":
|
|
1655
|
+
return { ok: false, code: "store_closed", message: "The graph store is closed." };
|
|
1656
|
+
default:
|
|
1657
|
+
return {
|
|
1658
|
+
ok: false,
|
|
1659
|
+
code: "db_error",
|
|
1660
|
+
message: `Database error: ${f.code}`,
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1666
|
+
// Public execute API.
|
|
1667
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* Parse and execute a Cypher query against a store. Convenience wrapper
|
|
1671
|
+
* around {@link parseCypher} + {@link executeAst}.
|
|
1672
|
+
*
|
|
1673
|
+
* @example
|
|
1674
|
+
* const r = executeCypher(store, 'MATCH (f:Function {name: "foo"})-[:CALLS*1..2]->(g) WHERE g.label = "function" RETURN f.name, g.qualifiedName LIMIT 5');
|
|
1675
|
+
* if (r.ok) for (const row of r.rows) console.log(row);
|
|
1676
|
+
*/
|
|
1677
|
+
export function executeCypher(store: GraphStore, query: string): CypherResult {
|
|
1678
|
+
const parsed = parseCypher(query);
|
|
1679
|
+
if (!parsed.ok) return parsed;
|
|
1680
|
+
return executeAst(store, parsed.ast);
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1684
|
+
// Exports for tests / type narrowing.
|
|
1685
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
1686
|
+
|
|
1687
|
+
export {
|
|
1688
|
+
CYPHER_LABEL_TO_DB_LABEL,
|
|
1689
|
+
VALID_CYPHER_LABELS,
|
|
1690
|
+
type CypherAst,
|
|
1691
|
+
type CypherParseError,
|
|
1692
|
+
};
|