• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

orneryd / NornicDB / 28594755032

02 Jul 2026 01:42PM UTC coverage: 89.0% (-0.08%) from 89.08%
28594755032

Pull #253

github

orneryd
addressing PR feedback, fix pruning, namespace leak, cleaning up key parsing
Pull Request #253: feat(cypher,storage): adding adjacency snapshot isolation api

310 of 506 new or added lines in 9 files covered. (61.26%)

27 existing lines in 11 files now uncovered.

145645 of 163646 relevant lines covered (89.0%)

1.04 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.41
/pkg/cypher/executor.go
1
// Package cypher provides Neo4j-compatible Cypher query execution for NornicDB.
2
//
3
// This package implements a Cypher query parser and executor that supports
4
// the core Neo4j Cypher query language features. It enables NornicDB to be
5
// compatible with existing Neo4j applications and tools.
6
//
7
// Supported Cypher Features:
8
//   - MATCH: Pattern matching with node and relationship patterns
9
//   - CREATE: Creating nodes and relationships
10
//   - MERGE: Upsert operations with ON CREATE/ON MATCH clauses
11
//   - DELETE/DETACH DELETE: Removing nodes and relationships
12
//   - SET: Updating node and relationship properties
13
//   - REMOVE: Removing properties and labels
14
//   - RETURN: Returning query results
15
//   - WHERE: Filtering with conditions
16
//   - WITH: Passing results between query parts
17
//   - OPTIONAL MATCH: Left outer joins
18
//   - CALL: Procedure calls
19
//   - UNWIND: List expansion
20
//
21
// Example Usage:
22
//
23
//        // Create executor with storage backend
24
//        storage := storage.NewMemoryEngine()
25
//        executor := cypher.NewStorageExecutor(storage)
26
//
27
//        // Execute Cypher queries
28
//        result, err := executor.Execute(ctx, "CREATE (n:Person {name: 'Alice', age: 30})", nil)
29
//        if err != nil {
30
//                log.Fatal(err)
31
//        }
32
//
33
//        // Query with parameters
34
//        params := map[string]interface{}{
35
//                "name": "Alice",
36
//                "minAge": 25,
37
//        }
38
//        result, err = executor.Execute(ctx,
39
//                "MATCH (n:Person {name: $name}) WHERE n.age >= $minAge RETURN n", params)
40
//
41
//        // Complex query with relationships
42
//        result, err = executor.Execute(ctx, `
43
//                MATCH (a:Person)-[r:KNOWS]->(b:Person)
44
//                WHERE a.age > 25
45
//                RETURN a.name, r.since, b.name
46
//                ORDER BY a.age DESC
47
//                LIMIT 10
48
//        `, nil)
49
//
50
//        // Process results
51
//        for _, row := range result.Rows {
52
//                // process row (e.g. emit "Row: %v" via the configured logger)
53
//        }
54
//
55
// Neo4j Compatibility:
56
//
57
// The executor aims for high compatibility with Neo4j Cypher:
58
//   - Same syntax and semantics for core operations
59
//   - Parameter substitution with $param syntax
60
//   - Neo4j-style error messages and codes
61
//   - Compatible result format for drivers
62
//   - Support for Neo4j built-in functions
63
//
64
// Query Processing Pipeline:
65
//
66
// 1. **Parsing**: Query is parsed into an AST (Abstract Syntax Tree)
67
// 2. **Validation**: Syntax and semantic validation
68
// 3. **Parameter Substitution**: Replace $param with actual values
69
// 4. **Execution Planning**: Determine optimal execution strategy
70
// 5. **Execution**: Execute against storage backend
71
// 6. **Result Formatting**: Format results for Neo4j compatibility
72
//
73
// Performance Considerations:
74
//
75
//   - Pattern matching is optimized for common cases
76
//   - Indexes are used automatically when available
77
//   - Query planning chooses efficient execution paths
78
//   - Bulk operations are optimized for large datasets
79
//
80
// Limitations:
81
//
82
// Current limitations compared to full Neo4j:
83
//   - No user-defined procedures (CALL is limited to built-ins)
84
//   - No complex path expressions
85
//   - No graph algorithms (shortest path, etc.)
86
//   - No schema constraints (handled by storage layer)
87
//   - No transactions (single-query atomicity only)
88
//
89
// ELI12 (Explain Like I'm 12):
90
//
91
// Think of Cypher like asking questions about a social network:
92
//
93
//  1. **MATCH**: "Find all people named Alice" - like searching through
94
//     a phone book for everyone with a specific name.
95
//
96
//  2. **CREATE**: "Add a new person named Bob" - like writing a new
97
//     entry in the phone book.
98
//
99
//  3. **Relationships**: "Find who Alice knows" - like following the
100
//     lines between people on a friendship map.
101
//
102
//  4. **WHERE**: "Find people older than 25" - like adding a filter
103
//     to only show certain results.
104
//
105
//  5. **RETURN**: "Show me their names and ages" - like deciding which
106
//     information to display from your search.
107
//
108
// The Cypher executor is like a smart assistant that understands these
109
// questions and knows how to find the answers in your data!
110
package cypher
111

112
import (
113
        "context"
114
        "errors"
115
        "fmt"
116
        "io"
117
        "log/slog"
118
        "strconv"
119
        "strings"
120
        "sync"
121
        "time"
122
        "unicode"
123

124
        "github.com/orneryd/nornicdb/pkg/config"
125
        "github.com/orneryd/nornicdb/pkg/cypher/antlr"
126
        "github.com/orneryd/nornicdb/pkg/embeddingutil"
127
        nornicerrors "github.com/orneryd/nornicdb/pkg/errors"
128
        "github.com/orneryd/nornicdb/pkg/fabric"
129
        "github.com/orneryd/nornicdb/pkg/heimdall"
130
        "github.com/orneryd/nornicdb/pkg/observability"
131
        "github.com/orneryd/nornicdb/pkg/search"
132
        "github.com/orneryd/nornicdb/pkg/storage"
133
        "github.com/orneryd/nornicdb/pkg/vectorspace"
134
        "go.opentelemetry.io/otel/attribute"
135
)
136

137
// Subquery detection tags. Routing uses scanner helpers below rather than regex.
138
const (
139
        existsSubqueryRe    = "EXISTS"
140
        notExistsSubqueryRe = "NOT EXISTS"
141
        countSubqueryRe     = "COUNT"
142
        callSubqueryRe      = "CALL"
143
        collectSubqueryRe   = "COLLECT"
144
)
145

146
// hasSubqueryPattern checks if the query contains a subquery pattern (keyword + optional whitespace + brace)
147
func hasSubqueryPattern(query string, pattern string) bool {
1✔
148
        switch pattern {
1✔
149
        case existsSubqueryRe:
1✔
150
                return hasKeywordFollowedByBrace(query, "EXISTS")
1✔
151
        case notExistsSubqueryRe:
1✔
152
                return hasNotExistsFollowedByBrace(query)
1✔
153
        case countSubqueryRe:
1✔
154
                return hasKeywordFollowedByBrace(query, "COUNT")
1✔
155
        case callSubqueryRe:
1✔
156
                return hasCallSubqueryPattern(query)
1✔
157
        case collectSubqueryRe:
1✔
158
                return hasKeywordFollowedByBrace(query, "COLLECT")
1✔
159
        }
160
        return false
×
161
}
162

163
func hasCallSubqueryPattern(query string) bool {
1✔
164
        for i := 0; i < len(query); i++ {
2✔
165
                if !matchKeywordAt(query, i, "CALL") {
2✔
166
                        continue
1✔
167
                }
168
                j := skipSpaces(query, i+len("CALL"))
1✔
169
                if j < len(query) && query[j] == '{' {
2✔
170
                        return true
1✔
171
                }
1✔
172
                if j >= len(query) || query[j] != '(' {
2✔
173
                        continue
1✔
174
                }
175
                close := findMatchingCallParen(query, j)
1✔
176
                if close < 0 {
1✔
177
                        continue
×
178
                }
179
                k := skipSpaces(query, close+1)
1✔
180
                if k < len(query) && query[k] == '{' {
2✔
181
                        return true
1✔
182
                }
1✔
183
        }
184
        return false
1✔
185
}
186

187
func hasCallInTransactions(query string) bool {
1✔
188
        return hasCallSubqueryPattern(query) && findKeywordIndex(query, "IN TRANSACTIONS") >= 0
1✔
189
}
1✔
190

191
func hasNotExistsFollowedByBrace(query string) bool {
1✔
192
        for i := 0; i < len(query); i++ {
2✔
193
                if !matchKeywordAt(query, i, "NOT") {
2✔
194
                        continue
1✔
195
                }
196
                j := skipSpaces(query, i+3)
1✔
197
                if !matchKeywordAt(query, j, "EXISTS") {
2✔
198
                        continue
1✔
199
                }
200
                k := skipSpaces(query, j+6)
1✔
201
                if k < len(query) && query[k] == '{' {
2✔
202
                        return true
1✔
203
                }
1✔
204
        }
205
        return false
1✔
206
}
207

208
func hasKeywordFollowedByBrace(query, keyword string) bool {
1✔
209
        kwLen := len(keyword)
1✔
210
        for i := 0; i < len(query); i++ {
2✔
211
                if !matchKeywordAt(query, i, keyword) {
2✔
212
                        continue
1✔
213
                }
214
                j := skipSpaces(query, i+kwLen)
1✔
215
                if j < len(query) && query[j] == '{' {
2✔
216
                        return true
1✔
217
                }
1✔
218
        }
219
        return false
1✔
220
}
221

222
func skipSpaces(s string, i int) int {
1✔
223
        for i < len(s) {
2✔
224
                switch s[i] {
1✔
225
                case ' ', '\t', '\n', '\r':
1✔
226
                        i++
1✔
227
                default:
1✔
228
                        return i
1✔
229
                }
230
        }
231
        return i
1✔
232
}
233

234
func matchKeywordAt(s string, i int, keyword string) bool {
1✔
235
        if i < 0 || i+len(keyword) > len(s) {
2✔
236
                return false
1✔
237
        }
1✔
238
        if i > 0 && isIdentCharByte(s[i-1]) {
2✔
239
                return false
1✔
240
        }
1✔
241
        if i+len(keyword) < len(s) && isIdentCharByte(s[i+len(keyword)]) {
2✔
242
                return false
1✔
243
        }
1✔
244
        return strings.EqualFold(s[i:i+len(keyword)], keyword)
1✔
245
}
246

247
func isIdentCharByte(b byte) bool {
1✔
248
        return b == '_' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
1✔
249
}
1✔
250

251
// StorageExecutor executes Cypher queries against a storage backend.
252
//
253
// The StorageExecutor provides the main interface for executing Cypher queries
254
// in NornicDB. It handles query parsing, validation, parameter substitution,
255
// and execution against the underlying storage engine.
256
//
257
// Key features:
258
//   - Neo4j-compatible Cypher syntax support
259
//   - Parameter substitution with $param syntax
260
//   - Query validation and error reporting
261
//   - Optimized execution planning
262
//   - Thread-safe concurrent execution
263
//
264
// Example:
265
//
266
//        storage := storage.NewMemoryEngine()
267
//        executor := cypher.NewStorageExecutor(storage)
268
//
269
//        // Simple node creation
270
//        result, _ := executor.Execute(ctx, "CREATE (n:Person {name: 'Alice'})", nil)
271
//
272
//        // Parameterized query
273
//        params := map[string]interface{}{"name": "Bob", "age": 30}
274
//        result, _ = executor.Execute(ctx,
275
//                "CREATE (n:Person {name: $name, age: $age})", params)
276
//
277
//        // Complex pattern matching
278
//        result, _ = executor.Execute(ctx, `
279
//                MATCH (a:Person)-[:KNOWS]->(b:Person)
280
//                WHERE a.age > 25
281
//                RETURN a.name, b.name
282
//        `, nil)
283
//
284
// Thread Safety:
285
//
286
//        The executor is thread-safe and can handle concurrent queries.
287
//
288
// NodeMutatedCallback is called when a node is created or mutated via Cypher (CREATE, MERGE, SET, REMOVE, or procedures that update nodes).
289
// This allows external systems (like the embed queue) to be notified so embeddings can be (re)generated.
290
type NodeMutatedCallback func(nodeID string)
291

292
type StorageExecutor struct {
293
        parser    *Parser
294
        storage   storage.Engine
295
        txContext *TransactionContext // Active transaction context
296
        cache     *SmartQueryCache    // Query result cache with label-aware invalidation
297
        planCache *QueryPlanCache     // Parsed query plan cache
298
        // fabricPlanCache caches planned Fabric fragment trees (query + sessionDB).
299
        fabricPlanCache *fabric.PlanCache
300
        analyzer        *QueryAnalyzer // Query analysis with AST caching
301

302
        // Node lookup cache for MATCH patterns like (n:Label {prop: value})
303
        // Key: "Label:{prop:value,...}", Value: *storage.Node
304
        // This dramatically speeds up repeated MATCH lookups for the same pattern.
305
        //
306
        // Transaction scoping: cloneWithStorage gives transactional clones a
307
        // FRESH cache + mutex so concurrent transactions can't see each other's
308
        // uncommitted MERGE node-ID mappings. Without that scoping, two writers
309
        // MERGE-ing on the same (label, prop, value) would each populate the
310
        // shared cache pre-commit; the peer would then probe its own
311
        // tx.badgerTx for the cached uncommitted node ID, taking the peer's
312
        // node key into its read set, and Badger SSI would reject the loser
313
        // with "Transaction Conflict" instead of the consumer-pinned
314
        // commit-time UNIQUE shape.
315
        nodeLookupCache   map[string]*storage.Node
316
        nodeLookupCacheMu *sync.RWMutex
317

318
        // deferFlush when true, writes are not auto-flushed (Bolt layer handles it)
319
        deferFlush bool
320

321
        // embedder for server-side query embedding (optional)
322
        // If set, vector search can accept string queries which are embedded automatically
323
        embedder QueryEmbedder
324

325
        // searchService optionally provides unified search semantics for Cypher procedures.
326
        // When set, db.index.vector.queryNodes delegates to search.Service.
327
        searchService *search.Service
328

329
        // inferenceManager optionally provides LLM inference for db.infer.
330
        inferenceManager InferenceManager
331

332
        // onNodeMutated is called when a node is created or mutated (CREATE, MERGE, SET, REMOVE).
333
        // This allows the embed queue to be notified so embeddings are (re)generated.
334
        onNodeMutated               NodeMutatedCallback
335
        inlineEmbeddingTextOptions  *embeddingutil.EmbedTextOptions
336
        inlineEmbeddingChunkSize    int
337
        inlineEmbeddingChunkOverlap int
338

339
        // defaultEmbeddingDimensions is the configured embedding dimensions for vector indexes
340
        // Used as default when CREATE VECTOR INDEX doesn't specify dimensions
341
        defaultEmbeddingDimensions int
342

343
        // dbManager is optional - when set, enables system commands (CREATE/DROP/SHOW DATABASE)
344
        // System commands require DatabaseManager to manage multiple databases
345
        // This is an interface to avoid import cycles with multidb package
346
        dbManager DatabaseManagerInterface
347

348
        // shellParams stores Neo4j shell-style parameters set via :param / :params.
349
        // These are session-scoped to the executor instance and merged with per-call params.
350
        shellParams   map[string]interface{}
351
        shellParamsMu sync.RWMutex
352

353
        // vectorRegistry maps Cypher vector index definitions to concrete vector spaces.
354
        vectorRegistry    *vectorspace.IndexRegistry
355
        vectorIndexSpaces map[string]vectorspace.VectorSpaceKey
356
        // fabricRecordBindings carries correlated APPLY input bindings for Fabric execution.
357
        // It is set only on per-query cloned executors.
358
        fabricRecordBindings map[string]interface{}
359

360
        decayMismatchLogged bool
361
        hotPathTraceState   *hotPathTraceState
362

363
        // vectorQueryEmbedCache caches server-side embeddings for db.index.vector.queryNodes/
364
        // queryRelationships string-input mode to avoid repeated embedding latency.
365
        // Key is canonicalized (case/whitespace normalized) query text.
366
        vectorQueryEmbedCache map[string][]float32
367
        // vectorQueryEmbedInflight de-duplicates concurrent embedding work per key.
368
        vectorQueryEmbedInflight map[string]*vectorEmbedInflight
369
        vectorQueryEmbedMu       sync.Mutex
370

371
        // unwindMergeChainPlanCache memoizes parsed plans for the generalized
372
        // UNWIND ... MERGE batch hot path keyed by mutation query text.
373
        unwindMergeChainPlanCache *unwindMergeChainPlanCache
374
        // upperQueryCache memoizes uppercase routing forms for exact query text
375
        // to avoid repeated strings.ToUpper allocations on hot query shapes.
376
        //
377
        // Initialized lazily via upperQueryCacheOnce so concurrent CALL { ... }
378
        // subqueries that share an executor pointer don't race on the lazy
379
        // install. See ensureUpperQueryCache for the matching helper.
380
        upperQueryCache     *upperQueryCache
381
        upperQueryCacheOnce sync.Once
382
        // syntaxValidationCache memoizes successful Nornic-parser syntax checks
383
        // for exact query text to avoid repeated bracket/string scans on hot loops.
384
        //
385
        // The cache pointer is lazily installed via syntaxValidationOnce so that
386
        // concurrent callers in parallel CALL { ... } / executeCallTailParallel
387
        // do not race on the pointer write — the goroutine fanout in call.go
388
        // previously triggered a data race detected by `go test -race`.
389
        syntaxValidationCache *syntaxValidationCache
390
        syntaxValidationOnce  sync.Once
391

392
        // log is the structured logger used for slow-query and operational log
393
        // emission. Threaded via SetLogger after construction (D-01 non-breaking
394
        // pattern — NewStorageExecutor signature unchanged). Nil-safe via the
395
        // internal logger() helper which lazily installs a discard fallback.
396
        log *slog.Logger
397

398
        // slowQueryThreshold gates the D-04c slow-query emission path. Zero or
399
        // negative values disable slow-query logging entirely. Set via
400
        // SetSlowQueryThreshold so the configured cfg.Logging.SlowQueryThreshold
401
        // flows in from the bootstrap site without breaking the ctor.
402
        slowQueryThreshold time.Duration
403

404
        // metrics is the Plan 04-03 CypherMetrics typed bag (MET-08). Injected
405
        // post-construction via SetCypherMetrics (D-01 non-breaking pattern,
406
        // mirrors SetLogger / SetSlowQueryThreshold). Nil-safe: the
407
        // observe* helpers no-op when metrics is nil so tests and alternate
408
        // constructors don't have to wire it.
409
        metrics *observability.CypherMetrics
410

411
        // database is the tenant identifier passed as the `database` label
412
        // on tenant-tagged Cypher families when D-08 tenantLabelsEnabled=true.
413
        // Empty string is acceptable when the bag was constructed with the
414
        // tenant flag off — Bind helpers drop the arg internally.
415
        database string
416
}
417

418
type unwindMergeChainPlanCache struct {
419
        mu    sync.RWMutex
420
        plans map[string]unwindMergeChainPlan
421
}
422

423
type upperQueryCache struct {
424
        mu    sync.RWMutex
425
        cache map[string]string
426
        max   int
427
}
428

429
type syntaxValidationCache struct {
430
        mu    sync.RWMutex
431
        cache map[string]struct{}
432
        max   int
433
}
434

435
func (e *StorageExecutor) cloneWithStorage(override storage.Engine) *StorageExecutor {
1✔
436
        e.ensureNodeLookupCache()
1✔
437
        // Transactional clones use the lookup cache pinned to the
1✔
438
        // transactionStorageWrapper. Concurrent transactions hold distinct
1✔
439
        // wrappers and therefore distinct caches — that isolation prevents
1✔
440
        // a peer's uncommitted node-ID mapping from leaking into this
1✔
441
        // transaction's tx.badgerTx read set (and corrupting Badger SSI
1✔
442
        // conflict shapes). Re-entrant Execute calls within the same
1✔
443
        // transaction reuse the wrapper, so the in-tx cache survives
1✔
444
        // across clauses. On successful commit, callers promote the
1✔
445
        // wrapper-scoped entries back into the parent executor via
1✔
446
        // promoteNodeLookupCacheTo so subsequent Execute calls still
1✔
447
        // benefit from the cross-query speedup.
1✔
448
        lookupCache := e.nodeLookupCache
1✔
449
        lookupCacheMu := e.nodeLookupCacheLock()
1✔
450
        if txWrapper, isTxScoped := override.(*transactionStorageWrapper); isTxScoped {
2✔
451
                // Seed the wrapper-scoped cache from the parent's committed
1✔
452
                // entries on first clone so subsequent Execute calls retain
1✔
453
                // the cross-query speedup. Concurrent transactions still hold
1✔
454
                // distinct wrappers — the seeding is a one-shot copy, not a
1✔
455
                // live alias, so peer-tx writes after this point cannot leak.
1✔
456
                txWrapper.ensureNodeLookupCacheLocked(e)
1✔
457
                lookupCache = txWrapper.txNodeLookupCache
1✔
458
                lookupCacheMu = txWrapper.txNodeLookupCacheMu
1✔
459
        }
1✔
460
        return &StorageExecutor{
1✔
461
                parser:                      e.parser,
1✔
462
                storage:                     override,
1✔
463
                txContext:                   e.txContext,
1✔
464
                cache:                       e.cache,
1✔
465
                planCache:                   e.planCache,
1✔
466
                fabricPlanCache:             e.fabricPlanCache,
1✔
467
                analyzer:                    e.analyzer,
1✔
468
                nodeLookupCache:             lookupCache,
1✔
469
                nodeLookupCacheMu:           lookupCacheMu,
1✔
470
                deferFlush:                  e.deferFlush,
1✔
471
                embedder:                    e.embedder,
1✔
472
                searchService:               e.searchService,
1✔
473
                inferenceManager:            e.inferenceManager,
1✔
474
                onNodeMutated:               e.onNodeMutated,
1✔
475
                inlineEmbeddingTextOptions:  e.inlineEmbeddingTextOptions,
1✔
476
                inlineEmbeddingChunkSize:    e.inlineEmbeddingChunkSize,
1✔
477
                inlineEmbeddingChunkOverlap: e.inlineEmbeddingChunkOverlap,
1✔
478
                defaultEmbeddingDimensions:  e.defaultEmbeddingDimensions,
1✔
479
                dbManager:                   e.dbManager,
1✔
480
                shellParams:                 e.shellParams,
1✔
481
                vectorRegistry:              e.vectorRegistry,
1✔
482
                vectorIndexSpaces:           e.vectorIndexSpaces,
1✔
483
                fabricRecordBindings:        e.fabricRecordBindings,
1✔
484
                hotPathTraceState:           e.hotPathTraceState,
1✔
485
                vectorQueryEmbedCache:       e.vectorQueryEmbedCache,
1✔
486
                vectorQueryEmbedInflight:    e.vectorQueryEmbedInflight,
1✔
487
                unwindMergeChainPlanCache:   e.unwindMergeChainPlanCache,
1✔
488
                upperQueryCache:             e.upperQueryCache,
1✔
489
                syntaxValidationCache:       e.syntaxValidationCache,
1✔
490
                // Plan 04-03: propagate the metrics bag + database label through
1✔
491
                // per-query / per-storage clones so observation chokepoints in
1✔
492
                // Execute() see the same bag regardless of clone depth.
1✔
493
                metrics:  e.metrics,
1✔
494
                database: e.database,
1✔
495
        }
1✔
496
}
497

498
func (e *StorageExecutor) nodeLookupCacheLock() *sync.RWMutex {
1✔
499
        if e.nodeLookupCacheMu == nil {
2✔
500
                e.nodeLookupCacheMu = &sync.RWMutex{}
1✔
501
        }
1✔
502
        return e.nodeLookupCacheMu
1✔
503
}
504

505
func (e *StorageExecutor) ensureNodeLookupCache() {
1✔
506
        cacheMu := e.nodeLookupCacheLock()
1✔
507
        cacheMu.Lock()
1✔
508
        defer cacheMu.Unlock()
1✔
509
        if e.nodeLookupCache == nil {
1✔
510
                e.nodeLookupCache = make(map[string]*storage.Node, 1000)
×
511
        }
×
512
}
513

514
type vectorEmbedInflight struct {
515
        done chan struct{}
516
        vec  []float32
517
        err  error
518
}
519

520
type hotPathTraceState struct {
521
        mu    sync.RWMutex
522
        trace HotPathTrace
523
}
524

525
// DatabaseManagerInterface is a minimal interface to avoid import cycles with multidb package.
526
// This allows the executor to call database management operations without directly
527
// depending on the multidb package.
528
type DatabaseManagerInterface interface {
529
        CreateDatabase(name string) error
530
        DropDatabase(name string) error
531
        ListDatabases() []DatabaseInfoInterface
532
        Exists(name string) bool
533
        CreateAlias(alias, databaseName string) error
534
        DropAlias(alias string) error
535
        ListAliases(databaseName string) map[string]string
536
        ResolveDatabase(nameOrAlias string) (string, error)
537
        SetDatabaseLimits(databaseName string, limits interface{}) error
538
        GetDatabaseLimits(databaseName string) (interface{}, error)
539
        // Composite database methods
540
        CreateCompositeDatabase(name string, constituents []interface{}) error
541
        DropCompositeDatabase(name string) error
542
        AddConstituent(compositeName string, constituent interface{}) error
543
        RemoveConstituent(compositeName string, alias string) error
544
        GetCompositeConstituents(compositeName string) ([]interface{}, error)
545
        ListCompositeDatabases() []DatabaseInfoInterface
546
        IsCompositeDatabase(name string) bool
547
        // GetStorageForUse returns the storage engine for a database, supporting
548
        // composite databases. authToken is forwarded for remote constituents.
549
        GetStorageForUse(name string, authToken string) (interface{}, error)
550
}
551

552
// DatabaseInfoInterface provides database metadata without importing multidb.
553
type DatabaseInfoInterface interface {
554
        Name() string
555
        Type() string
556
        Status() string
557
        IsDefault() bool
558
        CreatedAt() time.Time
559
}
560

561
// QueryEmbedder generates embeddings for search queries.
562
// This is a minimal interface to avoid import cycles with embed package.
563
type QueryEmbedder interface {
564
        Embed(ctx context.Context, text string) ([]float32, error)
565
        ChunkText(text string, maxTokens, overlap int) ([]string, error)
566
}
567

568
// InferenceManager is the minimal LLM contract used by Cypher db.infer.
569
// It mirrors Heimdall manager methods to keep adapters thin.
570
type InferenceManager interface {
571
        Generate(ctx context.Context, prompt string, params heimdall.GenerateParams) (string, error)
572
        Chat(ctx context.Context, req heimdall.ChatRequest) (*heimdall.ChatResponse, error)
573
}
574

575
// NewStorageExecutor creates a new Cypher executor with the given storage backend.
576
//
577
// The executor is initialized with a parser and connected to the storage engine.
578
// It's ready to execute Cypher queries immediately after creation.
579
//
580
// Parameters:
581
//   - store: Storage engine to execute queries against (required)
582
//
583
// Returns:
584
//   - StorageExecutor ready for query execution
585
//
586
// Example:
587
//
588
//        // Create storage and executor
589
//        storage := storage.NewMemoryEngine()
590
//        executor := cypher.NewStorageExecutor(storage)
591
//
592
//        // Executor is ready for queries
593
//        result, err := executor.Execute(ctx, "MATCH (n) RETURN count(n)", nil)
594
func NewStorageExecutor(store storage.Engine) *StorageExecutor {
1✔
595
        exec := &StorageExecutor{
1✔
596
                parser:                      NewParser(),
1✔
597
                storage:                     store,
1✔
598
                cache:                       NewSmartQueryCache(1000), // Query result cache with label-aware invalidation
1✔
599
                planCache:                   NewQueryPlanCache(500),   // Cache 500 parsed query plans
1✔
600
                fabricPlanCache:             fabric.NewPlanCache(500), // Cache 500 Fabric fragment plans
1✔
601
                analyzer:                    NewQueryAnalyzer(1000),   // Cache 1000 parsed query ASTs
1✔
602
                nodeLookupCache:             make(map[string]*storage.Node, 1000),
1✔
603
                nodeLookupCacheMu:           &sync.RWMutex{},
1✔
604
                shellParams:                 make(map[string]interface{}),
1✔
605
                searchService:               nil, // Lazy initialization - will be set via SetSearchService() to reuse DB's cached service
1✔
606
                vectorRegistry:              vectorspace.NewIndexRegistry(),
1✔
607
                vectorIndexSpaces:           make(map[string]vectorspace.VectorSpaceKey),
1✔
608
                hotPathTraceState:           &hotPathTraceState{},
1✔
609
                vectorQueryEmbedCache:       make(map[string][]float32, 512),
1✔
610
                vectorQueryEmbedInflight:    make(map[string]*vectorEmbedInflight, 64),
1✔
611
                unwindMergeChainPlanCache:   &unwindMergeChainPlanCache{plans: make(map[string]unwindMergeChainPlan, 128)},
1✔
612
                inlineEmbeddingTextOptions:  embeddingutil.EmbedTextOptionsFromConfig(config.LoadFromEnv()),
1✔
613
                inlineEmbeddingChunkSize:    maxInt(config.LoadFromEnv().EmbeddingWorker.ChunkSize, 1),
1✔
614
                inlineEmbeddingChunkOverlap: maxInt(config.LoadFromEnv().EmbeddingWorker.ChunkOverlap, 0),
1✔
615
        }
1✔
616
        ensureBuiltInProceduresRegistered()
1✔
617
        _ = exec.loadPersistedProcedures()
1✔
618
        return exec
1✔
619
}
1✔
620

621
// ClearQueryCaches clears executor-local caches that can retain stale read results.
622
func (e *StorageExecutor) ClearQueryCaches() {
1✔
623
        if e.cache != nil {
2✔
624
                e.cache.Invalidate()
1✔
625
        }
1✔
626
        if e.planCache != nil {
2✔
627
                e.planCache.Clear()
1✔
628
        }
1✔
629
        if e.analyzer != nil {
2✔
630
                e.analyzer.ClearCache()
1✔
631
        }
1✔
632
        cacheMu := e.nodeLookupCacheLock()
1✔
633
        cacheMu.Lock()
1✔
634
        e.nodeLookupCache = make(map[string]*storage.Node, 1000)
1✔
635
        cacheMu.Unlock()
1✔
636
}
637

638
// InvalidateEntityCaches evicts targeted cache entries affected by a specific entity state change.
639
func (e *StorageExecutor) InvalidateEntityCaches(entityID string, tokens []string) {
1✔
640
        if e.cache != nil && len(tokens) > 0 {
2✔
641
                e.cache.InvalidateLabels(tokens)
1✔
642
        }
1✔
643
        e.invalidateNodeLookupCacheForEntityID(storage.NodeID(entityID))
1✔
644
}
645

646
func (e *StorageExecutor) invalidateNodeLookupCacheForEntityID(entityID storage.NodeID) {
1✔
647
        if entityID == "" {
1✔
648
                return
×
649
        }
×
650
        cacheMu := e.nodeLookupCacheLock()
1✔
651
        cacheMu.Lock()
1✔
652
        for key, node := range e.nodeLookupCache {
1✔
653
                if node != nil && node.ID == entityID {
×
654
                        delete(e.nodeLookupCache, key)
×
655
                }
×
656
        }
657
        cacheMu.Unlock()
1✔
658
}
659

660
// SetLogger installs the structured slog.Logger used for slow-query and
661
// operational records. D-01 non-breaking: NewStorageExecutor's signature is
662
// unchanged; callers (cmd/nornicdb/main.go) call SetLogger after construction
663
// so the *slog.Logger from observability.NewLogger flows through.
664
//
665
// Discard-fallback: passing nil installs a slog.Logger backed by io.Discard
666
// so subsequent log emissions cannot panic. The "component" attribute is
667
// pre-bound here (not per-call) to honor the RESEARCH "Per-call .With()
668
// allocation" anti-pattern.
669
func (e *StorageExecutor) SetLogger(logger *slog.Logger) {
1✔
670
        if logger == nil {
2✔
671
                logger = slog.New(slog.NewTextHandler(io.Discard, nil))
1✔
672
        }
1✔
673
        e.log = logger.With("component", "cypher")
1✔
674
}
675

676
// SetSlowQueryThreshold configures the D-04c slow-query emission gate.
677
// Zero or negative durations disable slow-query logging entirely. Threaded
678
// from cfg.Logging.SlowQueryThreshold at the bootstrap site.
679
func (e *StorageExecutor) SetSlowQueryThreshold(d time.Duration) {
1✔
680
        e.slowQueryThreshold = d
1✔
681
}
1✔
682

683
// Logger returns the bound *slog.Logger. Exposed so transient executors
684
// (e.g., per-transaction sessions cloned from a base) can inherit the
685
// configured logger without re-threading from main.
686
func (e *StorageExecutor) Logger() *slog.Logger { return e.logger() }
1✔
687

688
// SlowQueryThreshold returns the configured slow-query emission gate.
689
// Exposed so cloned executors inherit the threshold from their base.
690
func (e *StorageExecutor) SlowQueryThreshold() time.Duration { return e.slowQueryThreshold }
1✔
691

692
// SetCypherMetrics installs the Plan 04-03 CypherMetrics typed bag (MET-08)
693
// and the database label value passed on tenant-tagged families when D-08
694
// tenantLabelsEnabled=true. Mirrors the SetLogger / SetSlowQueryThreshold
695
// non-breaking pattern (D-01 non-breaking ctor).
696
//
697
// Also propagates the bag into the executor's owned planCache so the
698
// planner_cache_{hits,misses,size} families fire from QueryPlanCache.Get/Put
699
// without callers having to reach into private fields.
700
//
701
// Nil-safe: passing m=nil leaves observation as a no-op so tests and
702
// alternate constructors that don't wire metrics don't have to. The three
703
// observation chokepoints in Execute() guard on m == nil.
704
//
705
// Cloned executors inherit metrics + database via cloneWithStorage so the
706
// bag flows through per-query / per-tx scoped clones.
707
func (e *StorageExecutor) SetCypherMetrics(m *observability.CypherMetrics, database string) {
1✔
708
        e.metrics = m
1✔
709
        e.database = database
1✔
710
        // D-12a planner cache wiring: propagate into the owned planCache so
1✔
711
        // the cypher subsystem's planner_cache_{hits,misses,size} families
1✔
712
        // observe automatically.
1✔
713
        if e.planCache != nil {
2✔
714
                e.planCache.SetCypherMetrics(m)
1✔
715
        }
1✔
716
}
717

718
// SetCacheMetrics installs the Plan 04-01 cross-cutting CacheMetrics bag
719
// for D-12a query-result cache observation. Routes the bag into the owned
720
// SmartQueryCache so cache_hits_total{cache="query_result"} +
721
// cache_misses_total + cache_evictions_total emit on every Get/Put/Evict.
722
//
723
// Nil-safe; mirrors SetCypherMetrics shape.
724
func (e *StorageExecutor) SetCacheMetrics(m *observability.CacheMetrics) {
1✔
725
        if e.cache != nil {
2✔
726
                e.cache.SetCacheMetrics(m)
1✔
727
        }
1✔
728
}
729

730
// CypherMetrics returns the injected metrics bag (or nil if unset). Exposed
731
// so cloned executors can re-inject when constructed via newTxScopedExecutor
732
// outside the cloneWithStorage pathway.
733
func (e *StorageExecutor) CypherMetrics() *observability.CypherMetrics { return e.metrics }
1✔
734

735
// Database returns the configured database label value used for tenant-tagged
736
// Cypher metric observations (D-08).
737
func (e *StorageExecutor) Database() string { return e.database }
1✔
738

739
// observeQuery is the single Cypher-side observation helper. Called at the
740
// three RISK-1 corrected chokepoints in Execute():
741
//
742
//        Site 1 — admin dispatch       (op_type="admin",       observeDuration=true)
743
//        Site 2 — parse-error          (op_type="parse_error", observeDuration=false)
744
//        Site 3 — normal-path-after-Analyze (op_type from classifyOpType, observeDuration=true)
745
//
746
// Nil-safe: no-ops when e.metrics is nil. Hot-path-cheap: per-call Bind via
747
// the bag's BindQueryDuration helper (one WithLabelValues lookup); future
748
// optimization can hoist Bind into struct fields cached at SetCypherMetrics
749
// time per MET-25 — see RowsReturned for the precedent. The current shape
750
// keeps SetCypherMetrics simple while still emitting via the typed bag.
751
func (e *StorageExecutor) observeQuery(opType string, observeDuration bool, start time.Time) {
1✔
752
        if e.metrics == nil {
2✔
753
                return
1✔
754
        }
1✔
755
        e.metrics.BindQueries(opType, e.database).Inc()
1✔
756
        if observeDuration {
2✔
757
                e.metrics.BindQueryDuration(opType, e.database).Observe(context.Background(), time.Since(start).Seconds())
1✔
758
        }
1✔
759
}
760

761
// observeTransactionConflict is the D-16 chokepoint helper: storage detects
762
// (returns storage.ErrConflict from the engine), Cypher counts (here, in the
763
// transaction-wrapper site that surfaces ErrConflict to the caller). Storage
764
// layer never imports observability — preserves AGENTS.md §8 separation.
765
//
766
// Nil-safe: no-ops when e.metrics is nil OR err is not ErrConflict OR err is
767
// nil. Defensive: errors.Is check rather than equality so wrapped errors
768
// (fmt.Errorf("...: %w", storage.ErrConflict)) still classify correctly.
769
func (e *StorageExecutor) observeTransactionConflict(err error) {
1✔
770
        if e.metrics == nil || err == nil {
2✔
771
                return
1✔
772
        }
1✔
773
        if !errors.Is(err, storage.ErrConflict) {
2✔
774
                return
1✔
775
        }
1✔
776
        e.metrics.BindTransactionConflicts(e.database).Inc()
1✔
777
}
778

779
// observeTransactionBegin increments the active_transactions gauge. Pair
780
// with observeTransactionEnd at every Commit/Rollback site so the gauge
781
// balances to 0 across normal, abort, and panic paths.
782
func (e *StorageExecutor) observeTransactionBegin() {
1✔
783
        if e.metrics == nil {
1✔
784
                return
×
785
        }
×
786
        e.metrics.ActiveTransactions.Inc()
1✔
787
}
788

789
// observeTransactionEnd decrements the active_transactions gauge. See
790
// observeTransactionBegin.
791
func (e *StorageExecutor) observeTransactionEnd() {
1✔
792
        if e.metrics == nil {
1✔
793
                return
×
794
        }
×
795
        e.metrics.ActiveTransactions.Dec()
1✔
796
}
797

798
// observeSlowQueryIfThresholded increments the slow_queries counter when
799
// duration meets the configured slowQueryThreshold (matches the Phase 2
800
// D-04c emitSlowQueryLog gate semantics: zero or negative threshold
801
// disables emission entirely).
802
func (e *StorageExecutor) observeSlowQueryIfThresholded(duration time.Duration) {
1✔
803
        if e.metrics == nil {
2✔
804
                return
1✔
805
        }
1✔
806
        if e.slowQueryThreshold <= 0 || duration < e.slowQueryThreshold {
2✔
807
                return
1✔
808
        }
1✔
809
        e.metrics.BindSlowQueries(e.database).Inc()
1✔
810
}
811

812
// logger returns the bound logger, lazily installing a discard fallback if
813
// SetLogger was never called. Internal — every emission site must read the
814
// logger via this helper, never via the stdlib package-level default
815
// (LOG-09 forbids that path).
816
func (e *StorageExecutor) logger() *slog.Logger {
1✔
817
        if e.log == nil {
2✔
818
                e.log = slog.New(slog.NewTextHandler(io.Discard, nil)).With("component", "cypher")
1✔
819
        }
1✔
820
        return e.log
1✔
821
}
822

823
// emitSlowQueryLog writes a single WARN record matching the LOG-07 schema
824
// when duration meets the configured threshold. RedactLiterals runs BEFORE
825
// truncation per D-04c so partial literals never leak via the truncation seam.
826
//
827
// Schema (D-04c):
828
//
829
//        level=WARN
830
//        msg="slow query"
831
//        event="slow_query"
832
//        plan_hash=<16-char hex from PlanHash; "0000000000000000" when plan is nil>
833
//        cypher.duration_ms=<int64 millisecond delta>
834
//        query=<RedactLiterals(query) truncated to 500 chars>
835
//
836
// Performance: PlanHash + RedactLiterals only fire when this method is called,
837
// i.e., only when the executor's measured duration exceeded the configured
838
// threshold. The hot path (Execute fast return) never enters this method.
839
func (e *StorageExecutor) emitSlowQueryLog(query string, plan *ExecutionPlan, duration time.Duration) {
1✔
840
        if e.slowQueryThreshold <= 0 || duration < e.slowQueryThreshold {
2✔
841
                return
1✔
842
        }
1✔
843
        redacted := RedactLiterals(query)
1✔
844
        if len(redacted) > 500 {
2✔
845
                redacted = redacted[:500]
1✔
846
        }
1✔
847
        e.logger().Warn("slow query",
1✔
848
                "event", "slow_query",
1✔
849
                "plan_hash", PlanHash(plan),
1✔
850
                "cypher.duration_ms", duration.Milliseconds(),
1✔
851
                "query", redacted,
1✔
852
        )
1✔
853
}
854

855
// SetDatabaseManager sets the database manager for system commands.
856
// When set, enables CREATE DATABASE, DROP DATABASE, and SHOW DATABASES commands.
857
//
858
// Example:
859
//
860
//        executor := cypher.NewStorageExecutor(storage)
861
//        executor.SetDatabaseManager(dbManager)
862
//        // Now CREATE DATABASE, DROP DATABASE, SHOW DATABASES work
863
func (e *StorageExecutor) SetDatabaseManager(dbManager DatabaseManagerInterface) {
1✔
864
        e.dbManager = dbManager
1✔
865
}
1✔
866

867
// SetEmbedder sets the query embedder for server-side embedding.
868
// When set, db.index.vector.queryNodes can accept string queries
869
// which are automatically embedded before search.
870
//
871
// Example:
872
//
873
//        executor := cypher.NewStorageExecutor(storage)
874
//        executor.SetEmbedder(embedder)
875
//
876
//        // Now vector search accepts both:
877
//        // CALL db.index.vector.queryNodes('idx', 10, [0.1, 0.2, ...])  // Vector
878
//        // CALL db.index.vector.queryNodes('idx', 10, 'search query')   // String (auto-embedded)
879
func (e *StorageExecutor) SetEmbedder(embedder QueryEmbedder) {
1✔
880
        e.embedder = embedder
1✔
881
        e.vectorQueryEmbedMu.Lock()
1✔
882
        e.vectorQueryEmbedCache = make(map[string][]float32, 512)
1✔
883
        e.vectorQueryEmbedInflight = make(map[string]*vectorEmbedInflight, 64)
1✔
884
        e.vectorQueryEmbedMu.Unlock()
1✔
885
}
1✔
886

887
// SetSearchService sets the unified search service used by Cypher procedures.
888
// When set, db.index.vector.queryNodes will delegate to search.Service.
889
func (e *StorageExecutor) SetSearchService(svc *search.Service) {
1✔
890
        e.searchService = svc
1✔
891
}
1✔
892

893
// SetInferenceManager sets the inference manager used by db.infer.
894
func (e *StorageExecutor) SetInferenceManager(mgr InferenceManager) {
1✔
895
        e.inferenceManager = mgr
1✔
896
}
1✔
897

898
// GetInferenceManager returns the configured inference manager.
899
func (e *StorageExecutor) GetInferenceManager() InferenceManager {
1✔
900
        return e.inferenceManager
1✔
901
}
1✔
902

903
// SetVectorRegistry allows wiring a shared index registry (e.g., per database).
904
// Defaults to an internal registry when not set.
905
func (e *StorageExecutor) SetVectorRegistry(reg *vectorspace.IndexRegistry) {
1✔
906
        if reg == nil {
2✔
907
                reg = vectorspace.NewIndexRegistry()
1✔
908
        }
1✔
909
        e.vectorRegistry = reg
1✔
910
}
911

912
// GetVectorRegistry exposes the current registry (for tests and adapters).
913
func (e *StorageExecutor) GetVectorRegistry() *vectorspace.IndexRegistry {
1✔
914
        return e.vectorRegistry
1✔
915
}
1✔
916

917
// GetEmbedder returns the query embedder if set.
918
// This allows copying the embedder to namespaced executors for GraphQL.
919
func (e *StorageExecutor) GetEmbedder() QueryEmbedder {
1✔
920
        return e.embedder
1✔
921
}
1✔
922

923
// SetNodeMutatedCallback sets a callback that is invoked when nodes are created
924
// or mutated (CREATE, MERGE, SET, REMOVE, or procedures that update nodes).
925
// This allows the embed queue to be notified so embeddings can be (re)generated.
926
//
927
// Example:
928
//
929
//        executor := cypher.NewStorageExecutor(storage)
930
//        executor.SetNodeMutatedCallback(func(nodeID string) {
931
//            embedQueue.Enqueue(nodeID)
932
//        })
933
func (e *StorageExecutor) SetNodeMutatedCallback(cb NodeMutatedCallback) {
1✔
934
        e.onNodeMutated = cb
1✔
935
}
1✔
936

937
// SetDefaultEmbeddingDimensions sets the default dimensions for vector indexes.
938
// This is used when CREATE VECTOR INDEX doesn't specify dimensions in OPTIONS.
939
func (e *StorageExecutor) SetDefaultEmbeddingDimensions(dims int) {
1✔
940
        e.defaultEmbeddingDimensions = dims
1✔
941
}
1✔
942

943
// GetDefaultEmbeddingDimensions returns the configured default embedding dimensions.
944
// Returns 1024 as fallback if not configured.
945
func (e *StorageExecutor) GetDefaultEmbeddingDimensions() int {
1✔
946
        return e.defaultEmbeddingDimensions
1✔
947
}
1✔
948

949
// notifyNodeMutated updates live search metadata and calls the onNodeMutated
950
// callback if set. Call after any node creation or mutation (CREATE, MERGE,
951
// SET, REMOVE) so search sees client-supplied vectors immediately and the
952
// embed queue can re-process.
953
func (e *StorageExecutor) notifyNodeMutated(nodeID string) {
1✔
954
        if e.searchService != nil && nodeID != "" {
2✔
955
                if node, err := e.storage.GetNode(storage.NodeID(nodeID)); err == nil && node != nil {
2✔
956
                        _ = e.searchService.IndexNode(node)
1✔
957
                }
1✔
958
        }
959
        if e.onNodeMutated != nil {
2✔
960
                e.onNodeMutated(nodeID)
1✔
961
        }
1✔
962
}
963

964
// notifyEdgeMutated updates live search metadata after a relationship create or
965
// mutation so relationship vector queries can use client-supplied vectors before
966
// a full search warmup/build has run.
967
func (e *StorageExecutor) notifyEdgeMutated(edgeID string) {
1✔
968
        if e.searchService == nil || edgeID == "" {
2✔
969
                return
1✔
970
        }
1✔
971
        if edge, err := e.storage.GetEdge(storage.EdgeID(edgeID)); err == nil && edge != nil {
×
972
                e.indexMutatedEdge(edge)
×
973
        }
×
974
}
975

976
func (e *StorageExecutor) indexMutatedEdge(edge *storage.Edge) {
1✔
977
        if e.searchService != nil && edge != nil {
2✔
978
                _ = e.searchService.IndexEdge(edge)
1✔
979
        }
1✔
980
}
981

982
// removeNodeFromSearch removes a node from the search service (vector/fulltext indexes).
983
// Call after successfully deleting a node via Cypher so embeddings are not left orphaned.
984
// nodeID may be prefixed (e.g. "nornic:xyz") or local ("xyz"); the search service expects local ID.
985
func (e *StorageExecutor) removeNodeFromSearch(nodeID string) {
1✔
986
        if e.searchService == nil || nodeID == "" {
2✔
987
                return
1✔
988
        }
1✔
989
        localID := nodeID
1✔
990
        if _, unprefixed, ok := storage.ParseDatabasePrefix(nodeID); ok {
2✔
991
                localID = unprefixed
1✔
992
        }
1✔
993
        _ = e.searchService.RemoveNode(storage.NodeID(localID))
1✔
994
}
995

996
// Flush persists all pending writes to storage.
997
// This implements FlushableExecutor for Bolt-level deferred commits.
998
func (e *StorageExecutor) Flush() error {
1✔
999
        if asyncEngine, ok := e.storage.(*storage.AsyncEngine); ok {
2✔
1000
                return asyncEngine.Flush()
1✔
1001
        }
1✔
1002
        return nil
1✔
1003
}
1004

1005
// SetDeferFlush enables/disables deferred flush mode.
1006
// When enabled, writes are not auto-flushed - the Bolt layer calls Flush().
1007
func (e *StorageExecutor) SetDeferFlush(enabled bool) {
1✔
1008
        e.deferFlush = enabled
1✔
1009
}
1✔
1010

1011
// queryDeletesNodes returns true if the query deletes nodes.
1012
// Returns false for relationship-only deletes (CREATE rel...DELETE rel pattern).
1013
func queryDeletesNodes(query string) bool {
1✔
1014
        // DETACH DELETE always deletes nodes
1✔
1015
        if strings.Contains(strings.ToUpper(query), "DETACH DELETE") {
2✔
1016
                return true
1✔
1017
        }
1✔
1018
        // Relationship pattern (has -[...]-> or <-[...]-) with CREATE+DELETE = relationship delete only
1019
        if strings.Contains(query, "]->(") || strings.Contains(query, ")<-[") {
2✔
1020
                return false
1✔
1021
        }
1✔
1022
        return true
1✔
1023
}
1024

1025
// Execute parses and executes a Cypher query with optional parameters.
1026
//
1027
// This is the main entry point for Cypher query execution. The method handles
1028
// the complete query lifecycle: parsing, validation, parameter substitution,
1029
// execution planning, and result formatting.
1030
//
1031
// Parameters:
1032
//   - ctx: Context for cancellation and timeouts
1033
//   - cypher: Cypher query string
1034
//   - params: Optional parameters for $param substitution
1035
//
1036
// Returns:
1037
//   - ExecuteResult with columns and rows
1038
//   - Error if query parsing or execution fails
1039
//
1040
// Example:
1041
//
1042
//        // Simple query without parameters
1043
//        result, err := executor.Execute(ctx, "MATCH (n:Person) RETURN n.name", nil)
1044
//        if err != nil {
1045
//                log.Fatal(err)
1046
//        }
1047
//
1048
//        // Parameterized query
1049
//        params := map[string]interface{}{
1050
//                "name": "Alice",
1051
//                "minAge": 25,
1052
//        }
1053
//        result, err = executor.Execute(ctx, `
1054
//                MATCH (n:Person {name: $name})
1055
//                WHERE n.age >= $minAge
1056
//                RETURN n.name, n.age
1057
//        `, params)
1058
//
1059
//        // Process results
1060
//        // emit "Columns: %v" via the configured logger
1061
//        for _, row := range result.Rows {
1062
//                // process row (e.g. emit "Row: %v" via the configured logger)
1063
//        }
1064
//
1065
// Supported Query Types:
1066
//
1067
//        Core Clauses:
1068
//        - MATCH: Pattern matching and traversal
1069
//        - OPTIONAL MATCH: Left outer joins (returns nulls for no matches)
1070
//        - CREATE: Node and relationship creation
1071
//        - MERGE: Upsert operations with ON CREATE SET / ON MATCH SET
1072
//        - DELETE / DETACH DELETE: Node and relationship deletion
1073
//        - SET: Property updates
1074
//        - REMOVE: Property and label removal
1075
//
1076
//        Projection & Chaining:
1077
//        - RETURN: Result projection with expressions, aliases, aggregations
1078
//        - WITH: Query chaining and intermediate aggregation
1079
//        - UNWIND: List expansion into rows
1080
//
1081
//        Filtering & Ordering:
1082
//        - WHERE: Filtering conditions (=, <>, <, >, <=, >=, IS NULL, IS NOT NULL, IN, CONTAINS, STARTS WITH, ENDS WITH, AND, OR, NOT)
1083
//        - ORDER BY: Result sorting (ASC/DESC)
1084
//        - SKIP / LIMIT: Pagination
1085
//
1086
//        Aggregation Functions:
1087
//        - COUNT, SUM, AVG, MIN, MAX, COLLECT
1088
//
1089
//        Procedures & Functions:
1090
//        - CALL: Procedure invocation (db.labels, db.propertyKeys, db.index.vector.*, etc.)
1091
//        - CALL {}: Subquery execution with UNION support
1092
//
1093
//        Advanced:
1094
//        - UNION / UNION ALL: Query composition
1095
//        - FOREACH: Iterative updates
1096
//        - LOAD CSV: Data import
1097
//        - EXPLAIN / PROFILE: Query analysis
1098
//        - SHOW: Schema introspection
1099
//
1100
//        Path Functions:
1101
//        - shortestPath / allShortestPaths
1102
//
1103
// Error Handling:
1104
//
1105
//        Returns detailed error messages for syntax errors, type mismatches,
1106
//        and execution failures with Neo4j-compatible error codes.
1107
func (e *StorageExecutor) Execute(ctx context.Context, cypher string, params map[string]interface{}) (result *ExecuteResult, retErr error) {
1✔
1108
        e.resetHotPathTrace()
1✔
1109
        defer func() {
2✔
1110
                if retErr == nil && result != nil {
2✔
1111
                        e.recordMaterializedResultAccess(result)
1✔
1112
                }
1✔
1113
        }()
1114

1115
        // TRC-15: top-level cypher execute span. Started before timing so the
1116
        // span duration matches the metric observation window exactly. The span
1117
        // is ended in the same defer that emits the slow-query log.
1118
        ctx, execSpan := startExecuteSpan(ctx, "", cypher)
1✔
1119

1✔
1120
        // TRC-17: propagate span context to the storage layer so storage spans
1✔
1121
        // nest as children of the cypher execute span.
1✔
1122
        if te, ok := e.storage.(*storage.TracedEngine); ok {
2✔
1123
                te.SetContext(ctx)
1✔
1124
        }
1✔
1125

1126
        // D-04c slow-query log timing. Captured at the top so the threshold check
1127
        // covers every Execute return path (early-out, fabric, normal). Pre-bind
1128
        // the original query text — by the time the deferred emission fires,
1129
        // `cypher` has been normalized; we want to log what the client submitted.
1130
        slowStart := time.Now()
1✔
1131
        originalCypher := cypher
1✔
1132
        defer func() {
2✔
1133
                dur := time.Since(slowStart)
1✔
1134
                // Plan is unavailable for non-EXPLAIN/PROFILE queries; pass nil and
1✔
1135
                // rely on PlanHash's zero-placeholder behavior. Phase 6 (TRC-04) will
1✔
1136
                // thread the planned tree here once cypher EXPLAIN refactoring is in.
1✔
1137
                e.emitSlowQueryLog(originalCypher, nil, dur)
1✔
1138
                // Plan 04-03 / MET-08 slow_queries_total: increments only when
1✔
1139
                // duration meets the configured threshold (matches the D-04c
1✔
1140
                // emitSlowQueryLog gate semantics — single threshold, single
1✔
1141
                // emission point per Execute return).
1✔
1142
                e.observeSlowQueryIfThresholded(dur)
1✔
1143
                recordSpanError(execSpan, retErr)
1✔
1144
                execSpan.End()
1✔
1145
        }()
1✔
1146
        // Normalize query: trim BOM (some clients send it) then whitespace
1147
        cypher = trimBOM(cypher)
1✔
1148
        cypher = normalizeCypherSyntaxConfusables(cypher)
1✔
1149
        cypher = strings.TrimSpace(cypher)
1✔
1150
        cypher = trimTrailingStatementDelimiters(cypher)
1✔
1151
        if cypher == "" {
2✔
1152
                return nil, fmt.Errorf("empty query")
1✔
1153
        }
1✔
1154

1155
        // Handle Neo4j shell/browser commands like :USE and :param before validation.
1156
        processedQuery, processedCtx, shellResult, err := e.preprocessShellCommands(ctx, cypher, params)
1✔
1157
        if err != nil {
1✔
1158
                return nil, err
×
1159
        }
×
1160
        ctx = processedCtx
1✔
1161
        cypher = processedQuery
1✔
1162
        if cypher == "" {
2✔
1163
                return shellResult, nil
1✔
1164
        }
1✔
1165

1166
        // Route multi-graph CALL { USE ... } queries through the Fabric planner/executor
1167
        // so subquery decomposition and cross-graph routing use a single deterministic path.
1168
        if e.shouldUseFabricPlanner(cypher) {
2✔
1169
                mergedParams := e.mergeShellParams(params)
1✔
1170
                ctx = context.WithValue(ctx, paramsKey, mergedParams)
1✔
1171
                info := e.analyzer.Analyze(cypher)
1✔
1172
                // Plan 04-03 Site 3 (fabric branch): isFabric=true → op_type="fabric"
1✔
1173
                // per RISK-1 corrected classifier. Observation pre-execute so the
1✔
1174
                // counter reflects intent regardless of execution outcome (errors
1✔
1175
                // still bucket by intended op_type, matching how queries_total works
1✔
1176
                // in Phase 3 reference catalogs).
1✔
1177
                e.observeQuery(classifyOpType(info, true /* isFabric */, false), true, slowStart)
1✔
1178
                execSpan.SetAttributes(attribute.String("cypher.op_type", "fabric"))
1✔
1179
                inExplicitTx := e.txContext != nil && e.txContext.active
1✔
1180
                preparedFabric, err := e.prepareFabricExecution(ctx, cypher)
1✔
1181
                if err != nil {
2✔
1182
                        return nil, err
1✔
1183
                }
1✔
1184
                ctx = context.WithValue(ctx, fabricPreparedExecKey{}, preparedFabric)
1✔
1185
                allowResultCache := !preparedFabric.hasRemote
1✔
1186

1✔
1187
                // Mirror normal query-cache policy for Fabric reads (autocommit only).
1✔
1188
                if allowResultCache && !inExplicitTx && info.IsReadOnly && e.cache != nil && isCacheableReadQuery(cypher) {
2✔
1189
                        if cached, found := e.cache.Get(cypher, mergedParams); found {
2✔
1190
                                return cached, nil
1✔
1191
                        }
1✔
1192
                }
1193

1194
                var result *ExecuteResult
1✔
1195
                var execErr error
1✔
1196
                // When an explicit transaction is active on a composite route, execute through
1✔
1197
                // the same FabricTransaction so many-read/one-write constraints are enforced
1✔
1198
                // across all statements in the session.
1✔
1199
                if inExplicitTx {
2✔
1200
                        if ftx, ok := e.txContext.tx.(*fabric.FabricTransaction); ok {
2✔
1201
                                result, execErr = e.executeViaPreparedFabricWithTx(ctx, cypher, mergedParams, ftx, false, preparedFabric)
1✔
1202
                        } else {
1✔
1203
                                result, execErr = e.executeViaFabric(ctx, cypher, mergedParams)
×
1204
                        }
×
1205
                } else {
1✔
1206
                        result, execErr = e.executeViaFabric(ctx, cypher, mergedParams)
1✔
1207
                }
1✔
1208
                if execErr != nil {
2✔
1209
                        return nil, execErr
1✔
1210
                }
1✔
1211

1212
                if allowResultCache && !inExplicitTx && info.IsReadOnly && e.cache != nil && isCacheableReadQuery(cypher) {
2✔
1213
                        ttl := 60 * time.Second
1✔
1214
                        if info.HasAggregation {
2✔
1215
                                ttl = 1 * time.Second
1✔
1216
                        }
1✔
1217
                        if info.HasCall || info.HasShow {
2✔
1218
                                ttl = 300 * time.Second
1✔
1219
                        }
1✔
1220
                        e.cache.Put(cypher, mergedParams, result, ttl)
1✔
1221
                }
1222

1223
                if info.IsWriteQuery && e.cache != nil {
2✔
1224
                        if len(info.Labels) > 0 {
2✔
1225
                                e.cache.InvalidateLabels(info.Labels)
1✔
1226
                        } else {
1✔
1227
                                e.cache.Invalidate()
×
1228
                        }
×
1229
                }
1230

1231
                return result, nil
1✔
1232
        }
1233

1234
        // Handle leading Cypher USE clause (openCypher multi-graph syntax).
1235
        if useDB, remaining, hasUse, err := parseLeadingUseClause(cypher); hasUse || err != nil {
2✔
1236
                if err != nil {
1✔
1237
                        return nil, err
×
1238
                }
×
1239
                scopedExec, resolvedDB, err := e.scopedExecutorForUse(useDB, GetAuthTokenFromContext(ctx))
1✔
1240
                if err != nil {
1✔
1241
                        return nil, err
×
1242
                }
×
1243
                ctx = context.WithValue(ctx, ctxKeyUseDatabase, resolvedDB)
1✔
1244
                if strings.TrimSpace(remaining) == "" {
1✔
1245
                        return &ExecuteResult{
×
1246
                                Columns: []string{"database"},
×
1247
                                Rows:    [][]interface{}{{resolvedDB}},
×
1248
                        }, nil
×
1249
                }
×
1250
                return scopedExec.Execute(ctx, remaining, params)
1✔
1251
        }
1252

1253
        // Reject data queries on composite root — callers must USE a constituent.
1254
        // System/admin commands (SHOW DATABASES, CREATE/DROP DATABASE, ALTER, SHOW COMPOSITE,
1255
        // SHOW CONSTITUENTS, SHOW ALIASES, SHOW LIMITS, BEGIN, COMMIT, ROLLBACK) are allowed.
1256
        if isCompositeRoot(e.storage) && !isCompositeAllowedCommand(cypher) {
2✔
1257
                return nil, fmt.Errorf("Neo.ClientError.Statement.NotAllowed: " +
1✔
1258
                        "Queries on composite databases require explicit graph targeting. " +
1✔
1259
                        "Use USE <composite>.<alias> to target a specific constituent")
1✔
1260
        }
1✔
1261

1262
        // Merge session-scoped shell parameters with per-call parameters.
1263
        // Explicit params win over shell params to preserve HTTP/Bolt semantics.
1264
        params = e.mergeShellParams(params)
1✔
1265

1✔
1266
        // Check for transaction control statements and transaction scripts FIRST.
1✔
1267
        // These are Nornic extensions and must bypass strict ANTLR validation.
1✔
1268
        if result, err := e.executeTransactionScript(ctx, cypher); result != nil || err != nil {
2✔
1269
                return result, err
1✔
1270
        }
1✔
1271
        if result, err := e.parseTransactionStatement(cypher); result != nil || err != nil {
2✔
1272
                return result, err
1✔
1273
        }
1✔
1274

1275
        // Validate basic syntax
1276
        if err := e.validateSyntax(cypher); err != nil {
2✔
1277
                // Plan 04-03 Site 2 (parse-error chokepoint): emit op_type="parse_error"
1✔
1278
                // per D-04b sixth enum value. No duration observation — parse cost is
1✔
1279
                // sub-microsecond and not meaningful to bucket. The queries_total
1✔
1280
                // counter still increments so the SRE can alert on parse-error rate.
1✔
1281
                e.observeQuery("parse_error", false /* observeDuration */, slowStart)
1✔
1282
                execSpan.SetAttributes(attribute.String("cypher.op_type", "parse_error"))
1✔
1283
                return nil, err
1✔
1284
        }
1✔
1285

1286
        // IMPORTANT: Do NOT substitute parameters before routing!
1287
        // We need to route the query based on the ORIGINAL query structure,
1288
        // not the substituted one. Otherwise, keywords inside parameter values
1289
        // (like 'MATCH (n) SET n.x = 1' stored as content) will be incorrectly
1290
        // detected as Cypher clauses.
1291
        //
1292
        // Parameter substitution happens AFTER routing, inside each handler.
1293
        // This matches Neo4j's architecture where params are kept separate.
1294

1295
        // Store params in context for handlers to use
1296
        ctx = context.WithValue(ctx, paramsKey, params)
1✔
1297

1✔
1298
        // Check query limits if storage engine supports it
1✔
1299
        // Uses interface{} to avoid importing multidb package (prevents circular dependencies)
1✔
1300
        var queryLimitCancel context.CancelFunc
1✔
1301
        if namespacedEngine, ok := e.storage.(interface {
1✔
1302
                GetQueryLimitChecker() interface {
1✔
1303
                        CheckQueryRate() error
1✔
1304
                        CheckQueryLimits(context.Context) (context.Context, context.CancelFunc, error)
1✔
1305
                }
1✔
1306
        }); ok {
1✔
1307
                if qlc := namespacedEngine.GetQueryLimitChecker(); qlc != nil {
×
1308
                        // Check query rate limit
×
1309
                        if err := qlc.CheckQueryRate(); err != nil {
×
1310
                                return nil, err
×
1311
                        }
×
1312

1313
                        // Check write rate limit for write queries
1314
                        // We need to check this early, but we don't know if it's a write query yet
1315
                        // So we'll check it in the write handlers too
1316

1317
                        // Apply query timeout and concurrent query limits
1318
                        var err error
×
1319
                        ctx, queryLimitCancel, err = qlc.CheckQueryLimits(ctx)
×
1320
                        if err != nil {
×
1321
                                return nil, err
×
1322
                        }
×
1323
                        // Ensure cancel is called when done
1324
                        defer func() {
×
1325
                                if queryLimitCancel != nil {
×
1326
                                        queryLimitCancel()
×
1327
                                }
×
1328
                        }()
1329
                }
1330
        }
1331

1332
        // TRC-15: plan span wraps the analysis/classification phase.
1333
        _, planSpan := startPlanSpan(ctx)
1✔
1334
        // Analyze query - uses cached analysis if available
1✔
1335
        // This extracts query metadata (HasMatch, IsReadOnly, Labels, etc.) once
1✔
1336
        // and caches it for repeated queries, avoiding redundant string parsing
1✔
1337
        info := e.analyzer.Analyze(cypher)
1✔
1338

1✔
1339
        // Plan 04-03 Sites 1 + 3 (RISK-1 corrected): classify ONCE post-Analyze.
1✔
1340
        // isFabric=false here because the fabric branch returns at line ~963
1✔
1341
        // before reaching this code path. isAdmin=true overrides the QueryInfo-
1✔
1342
        // derived classification (e.g., SHOW DATABASES would otherwise classify
1✔
1343
        // as "schema" via HasShow → IsSchemaQuery; D-04a says system/admin
1✔
1344
        // commands bucket as "admin" instead). Single observation point covers
1✔
1345
        // cache-hit early-return AND every downstream execution path so the
1✔
1346
        // counter reflects intent regardless of how the query resolves.
1✔
1347
        opType := classifyOpType(info, false /* isFabric */, isSystemCommandNoGraph(cypher))
1✔
1348
        e.observeQuery(opType, true /* observeDuration */, slowStart)
1✔
1349
        planSpan.SetAttributes(attribute.String("cypher.op_type", opType))
1✔
1350
        planSpan.End()
1✔
1351
        // Update the execute span with the resolved op_type.
1✔
1352
        execSpan.SetAttributes(attribute.String("cypher.op_type", opType))
1✔
1353

1✔
1354
        // For routing, we still need upperQuery for some handlers
1✔
1355
        // TODO: Migrate handlers to use QueryInfo directly
1✔
1356
        upperQuery := e.cachedUpperQuery(cypher)
1✔
1357

1✔
1358
        // Try cache for read-only queries only when cache policy allows it.
1✔
1359
        if info.IsReadOnly && e.cache != nil && isCacheableReadQuery(cypher) {
2✔
1360
                if cached, found := e.cache.Get(cypher, params); found {
2✔
1361
                        return cached, nil
1✔
1362
                }
1✔
1363
        }
1364

1365
        // Check for EXPLAIN/PROFILE execution modes (using cached analysis)
1366
        if info.HasExplain {
2✔
1367
                _, innerQuery := parseExecutionMode(cypher)
1✔
1368
                return e.executeExplain(ctx, innerQuery)
1✔
1369
        }
1✔
1370
        if info.HasProfile {
2✔
1371
                _, innerQuery := parseExecutionMode(cypher)
1✔
1372
                return e.executeProfile(ctx, innerQuery)
1✔
1373
        }
1✔
1374

1375
        // If in explicit transaction, execute within it
1376
        if e.txContext != nil && e.txContext.active {
2✔
1377
                return e.executeInTransaction(ctx, cypher, upperQuery)
1✔
1378
        }
1✔
1379

1380
        // System commands (CREATE/DROP DATABASE, SHOW DATABASES, etc.) must not use the async engine
1381
        // or implicit transactions: they operate on dbManager/metadata, not graph storage.
1382
        // Routing them through executeWithoutTransaction directly ensures correct handling and
1383
        // avoids the write path (tryAsyncCreateNodeBatch / executeWithImplicitTransaction).
1384
        if isSystemCommandNoGraph(cypher) {
2✔
1385
                result, err := e.executeWithoutTransaction(ctx, cypher, upperQuery)
1✔
1386
                if err != nil {
2✔
1387
                        return nil, err
1✔
1388
                }
1✔
1389
                return result, nil
1✔
1390
        }
1391

1392
        // Auto-commit single query - use async path for performance
1393
        // This uses AsyncEngine's write-behind cache instead of synchronous disk I/O
1394
        // For strict ACID, users should use explicit BEGIN/COMMIT transactions
1395
        result, err = e.executeImplicitAsync(ctx, cypher, upperQuery)
1✔
1396

1✔
1397
        // Apply result limit if set
1✔
1398
        if err == nil && result != nil {
2✔
1399
                if namespacedEngine, ok := e.storage.(interface {
1✔
1400
                        GetQueryLimitChecker() interface {
1✔
1401
                                GetQueryLimits() interface{}
1✔
1402
                        }
1✔
1403
                }); ok {
1✔
1404
                        if qlc := namespacedEngine.GetQueryLimitChecker(); qlc != nil {
×
1405
                                if queryLimits := qlc.GetQueryLimits(); queryLimits != nil {
×
1406
                                        // Type assert to check if it has MaxResults field
×
1407
                                        // We use reflection-like approach: check if it's a struct with MaxResults
×
1408
                                        if limits, ok := queryLimits.(interface {
×
1409
                                                GetMaxResults() int64
×
1410
                                        }); ok {
×
1411
                                                if maxResults := limits.GetMaxResults(); maxResults > 0 && int64(len(result.Rows)) > maxResults {
×
1412
                                                        // Truncate results to limit
×
1413
                                                        result.Rows = result.Rows[:maxResults]
×
1414
                                                }
×
1415
                                        }
1416
                                }
1417
                        }
1418
                }
1419
        }
1420

1421
        // Cache successful read-only queries.
1422
        //
1423
        // NOTE: Aggregation queries (COUNT/SUM/AVG/COLLECT/...) used to be excluded, but in practice they can still
1424
        // be expensive (edge scans, label scans, COLLECT materialization). Caching them is correctness-preserving as
1425
        // long as we invalidate on writes (which we do), so we cache them with a shorter TTL by default.
1426
        if err == nil && info.IsReadOnly && e.cache != nil && isCacheableReadQuery(cypher) {
2✔
1427
                // Determine TTL based on query type (using cached analysis)
1✔
1428
                ttl := 60 * time.Second // Default: 60s for data queries
1✔
1429
                if info.HasAggregation {
2✔
1430
                        ttl = 1 * time.Second // Conservative TTL for aggregations
1✔
1431
                }
1✔
1432
                if info.HasCall || info.HasShow {
2✔
1433
                        ttl = 300 * time.Second // 5 minutes for schema queries
1✔
1434
                }
1✔
1435
                e.cache.Put(cypher, params, result, ttl)
1✔
1436
        }
1437

1438
        // Invalidate caches on write operations (using cached analysis)
1439
        if info.IsWriteQuery {
2✔
1440
                // Only invalidate node lookup cache when NODES are deleted
1✔
1441
                // Relationship-only deletes (like benchmark CREATE rel DELETE rel) don't affect node cache
1✔
1442
                if info.HasDelete && queryDeletesNodes(cypher) {
2✔
1443
                        e.invalidateNodeLookupCache()
1✔
1444
                }
1✔
1445

1446
                // Invalidate query result cache using cached labels
1447
                if e.cache != nil {
2✔
1448
                        if len(info.Labels) > 0 {
2✔
1449
                                e.cache.InvalidateLabels(info.Labels)
1✔
1450
                        } else {
2✔
1451
                                e.cache.Invalidate()
1✔
1452
                        }
1✔
1453
                }
1454
        }
1455

1456
        return result, err
1✔
1457
}
1458

1459
// trimTrailingStatementDelimiters removes trailing Cypher statement delimiters
1460
// (';') and surrounding whitespace, while leaving internal semicolons untouched.
1461
// This mirrors Neo4j-compatible client behavior where a final semicolon is optional.
1462
func trimTrailingStatementDelimiters(query string) string {
1✔
1463
        s := strings.TrimSpace(query)
1✔
1464
        for {
2✔
1465
                if !strings.HasSuffix(s, ";") {
2✔
1466
                        return s
1✔
1467
                }
1✔
1468
                s = strings.TrimSpace(strings.TrimSuffix(s, ";"))
1✔
1469
        }
1470
}
1471

1472
func normalizeCypherSyntaxConfusables(query string) string {
1✔
1473
        if query == "" {
2✔
1474
                return query
1✔
1475
        }
1✔
1476
        // Fast path: common ASCII-only Cypher text has no confusables to normalize.
1477
        if isLikelyPlainASCIICypher(query) {
2✔
1478
                return query
1✔
1479
        }
1✔
1480

1481
        const (
1✔
1482
                normalizeDefault = iota
1✔
1483
                normalizeSingleQuoted
1✔
1484
                normalizeDoubleQuoted
1✔
1485
                normalizeBacktickQuoted
1✔
1486
                normalizeLineComment
1✔
1487
                normalizeBlockComment
1✔
1488
        )
1✔
1489

1✔
1490
        runes := []rune(query)
1✔
1491
        var builder strings.Builder
1✔
1492
        builder.Grow(len(query) + 8)
1✔
1493
        changed := false
1✔
1494
        state := normalizeDefault
1✔
1495

1✔
1496
        for i := 0; i < len(runes); i++ {
2✔
1497
                r := runes[i]
1✔
1498
                next := rune(0)
1✔
1499
                if i+1 < len(runes) {
2✔
1500
                        next = runes[i+1]
1✔
1501
                }
1✔
1502

1503
                switch state {
1✔
1504
                case normalizeDefault:
1✔
1505
                        switch {
1✔
1506
                        case r == '/' && next == '/':
1✔
1507
                                builder.WriteRune(r)
1✔
1508
                                builder.WriteRune(next)
1✔
1509
                                i++
1✔
1510
                                state = normalizeLineComment
1✔
1511
                                continue
1✔
1512
                        case r == '/' && next == '*':
1✔
1513
                                builder.WriteRune(r)
1✔
1514
                                builder.WriteRune(next)
1✔
1515
                                i++
1✔
1516
                                state = normalizeBlockComment
1✔
1517
                                continue
1✔
1518
                        case r == '\'':
1✔
1519
                                builder.WriteRune(r)
1✔
1520
                                state = normalizeSingleQuoted
1✔
1521
                                continue
1✔
1522
                        case r == '"':
1✔
1523
                                builder.WriteRune(r)
1✔
1524
                                state = normalizeDoubleQuoted
1✔
1525
                                continue
1✔
1526
                        case r == '`':
1✔
1527
                                builder.WriteRune(r)
1✔
1528
                                state = normalizeBacktickQuoted
1✔
1529
                                continue
1✔
1530
                        }
1531

1532
                        if replacement, ok := cypherSyntaxConfusableReplacement(r); ok {
2✔
1533
                                builder.WriteString(replacement)
1✔
1534
                                changed = changed || replacement != string(r)
1✔
1535
                                continue
1✔
1536
                        }
1537

1538
                        if replacement, ok := cypherWhitespaceReplacement(r); ok {
2✔
1539
                                builder.WriteRune(replacement)
1✔
1540
                                changed = changed || replacement != r
1✔
1541
                                continue
1✔
1542
                        }
1543

1544
                        if isIgnorableCypherFormatRune(r) {
2✔
1545
                                changed = true
1✔
1546
                                continue
1✔
1547
                        }
1548

1549
                        builder.WriteRune(r)
1✔
1550

1551
                case normalizeSingleQuoted:
1✔
1552
                        builder.WriteRune(r)
1✔
1553
                        if r == '\\' && i+1 < len(runes) {
1✔
1554
                                builder.WriteRune(runes[i+1])
×
1555
                                i++
×
1556
                                continue
×
1557
                        }
1558
                        if r == '\'' {
2✔
1559
                                if i+1 < len(runes) && runes[i+1] == '\'' {
2✔
1560
                                        builder.WriteRune(runes[i+1])
1✔
1561
                                        i++
1✔
1562
                                        continue
1✔
1563
                                }
1564
                                state = normalizeDefault
1✔
1565
                        }
1566

1567
                case normalizeDoubleQuoted:
1✔
1568
                        builder.WriteRune(r)
1✔
1569
                        if r == '\\' && i+1 < len(runes) {
1✔
1570
                                builder.WriteRune(runes[i+1])
×
1571
                                i++
×
1572
                                continue
×
1573
                        }
1574
                        if r == '"' {
2✔
1575
                                if i+1 < len(runes) && runes[i+1] == '"' {
2✔
1576
                                        builder.WriteRune(runes[i+1])
1✔
1577
                                        i++
1✔
1578
                                        continue
1✔
1579
                                }
1580
                                state = normalizeDefault
1✔
1581
                        }
1582

1583
                case normalizeBacktickQuoted:
1✔
1584
                        builder.WriteRune(r)
1✔
1585
                        if r == '`' {
2✔
1586
                                if i+1 < len(runes) && runes[i+1] == '`' {
2✔
1587
                                        builder.WriteRune(runes[i+1])
1✔
1588
                                        i++
1✔
1589
                                        continue
1✔
1590
                                }
1591
                                state = normalizeDefault
1✔
1592
                        }
1593

1594
                case normalizeLineComment:
1✔
1595
                        builder.WriteRune(r)
1✔
1596
                        if r == '\n' || r == '\r' {
2✔
1597
                                state = normalizeDefault
1✔
1598
                        }
1✔
1599

1600
                case normalizeBlockComment:
1✔
1601
                        builder.WriteRune(r)
1✔
1602
                        if r == '*' && next == '/' {
2✔
1603
                                builder.WriteRune(next)
1✔
1604
                                i++
1✔
1605
                                state = normalizeDefault
1✔
1606
                        }
1✔
1607
                }
1608
        }
1609

1610
        if !changed {
2✔
1611
                return query
1✔
1612
        }
1✔
1613

1614
        return builder.String()
1✔
1615
}
1616

1617
func isLikelyPlainASCIICypher(query string) bool {
1✔
1618
        for i := 0; i < len(query); i++ {
2✔
1619
                if query[i] >= 0x80 {
2✔
1620
                        return false
1✔
1621
                }
1✔
1622
        }
1623
        return true
1✔
1624
}
1625

1626
// ensureUpperQueryCache lazily installs the upper-query cache pointer with
1627
// sync.Once so concurrent CALL { ... } subqueries cannot race on the
1628
// pointer assignment. The cache itself is mutex-guarded for entry access.
1629
func (e *StorageExecutor) ensureUpperQueryCache() *upperQueryCache {
1✔
1630
        e.upperQueryCacheOnce.Do(func() {
2✔
1631
                if e.upperQueryCache == nil {
2✔
1632
                        e.upperQueryCache = &upperQueryCache{
1✔
1633
                                cache: make(map[string]string, 1024),
1✔
1634
                                max:   4096,
1✔
1635
                        }
1✔
1636
                }
1✔
1637
        })
1638
        return e.upperQueryCache
1✔
1639
}
1640

1641
func (e *StorageExecutor) cachedUpperQuery(query string) string {
1✔
1642
        trimmed := strings.TrimSpace(query)
1✔
1643
        if trimmed == "" {
1✔
1644
                return ""
×
1645
        }
×
1646
        c := e.ensureUpperQueryCache()
1✔
1647
        c.mu.RLock()
1✔
1648
        if upper, ok := c.cache[trimmed]; ok {
2✔
1649
                c.mu.RUnlock()
1✔
1650
                return upper
1✔
1651
        }
1✔
1652
        c.mu.RUnlock()
1✔
1653

1✔
1654
        upper := strings.ToUpper(trimmed)
1✔
1655
        c.mu.Lock()
1✔
1656
        if len(c.cache) >= c.max {
2✔
1657
                for k := range c.cache {
2✔
1658
                        delete(c.cache, k)
1✔
1659
                        break
1✔
1660
                }
1661
        }
1662
        c.cache[trimmed] = upper
1✔
1663
        c.mu.Unlock()
1✔
1664
        return upper
1✔
1665
}
1666

1667
func cypherSyntaxConfusableReplacement(r rune) (string, bool) {
1✔
1668
        switch r {
1✔
1669
        case '→':
1✔
1670
                return "->", true
1✔
1671
        case '←':
1✔
1672
                return "<-", true
1✔
1673
        case '—', '–', '−', '‐', '‑', '‒':
1✔
1674
                return "-", true
1✔
1675
        case '(':
1✔
1676
                return "(", true
1✔
1677
        case ')':
1✔
1678
                return ")", true
1✔
1679
        case '[':
1✔
1680
                return "[", true
1✔
1681
        case ']':
1✔
1682
                return "]", true
1✔
1683
        case '{':
1✔
1684
                return "{", true
1✔
1685
        case '}':
1✔
1686
                return "}", true
1✔
1687
        case ',':
1✔
1688
                return ",", true
1✔
1689
        case ':':
1✔
1690
                return ":", true
1✔
1691
        case ';':
1✔
1692
                return ";", true
1✔
1693
        case '.':
1✔
1694
                return ".", true
1✔
1695
        case '=':
1✔
1696
                return "=", true
1✔
1697
        case '<':
1✔
1698
                return "<", true
1✔
1699
        case '>':
1✔
1700
                return ">", true
1✔
1701
        case '$':
1✔
1702
                return "$", true
1✔
1703
        default:
1✔
1704
                return "", false
1✔
1705
        }
1706
}
1707

1708
func cypherWhitespaceReplacement(r rune) (rune, bool) {
1✔
1709
        switch r {
1✔
1710
        case '\u0085', '\u2028', '\u2029':
×
1711
                return '\n', true
×
1712
        case ' ', '\t', '\n', '\r':
1✔
1713
                return 0, false
1✔
1714
        default:
1✔
1715
                if unicode.IsSpace(r) {
2✔
1716
                        return ' ', true
1✔
1717
                }
1✔
1718
                return 0, false
1✔
1719
        }
1720
}
1721

1722
func isIgnorableCypherFormatRune(r rune) bool {
1✔
1723
        switch r {
1✔
1724
        case '\u200B', '\u200C', '\u200D', '\u2060', '\uFEFF':
1✔
1725
                return true
1✔
1726
        default:
1✔
1727
                return false
1✔
1728
        }
1729
}
1730

1731
// TransactionCapableEngine is an engine that supports ACID transactions.
1732
// Used for type assertion to wrap implicit writes in rollback-capable transactions.
1733
type TransactionCapableEngine interface {
1734
        BeginTransaction() (*storage.BadgerTransaction, error)
1735
}
1736

1737
type implicitTxEngines struct {
1738
        txEngine    TransactionCapableEngine
1739
        asyncEngine *storage.AsyncEngine
1740
        namespace   string
1741
}
1742

1743
func (e *StorageExecutor) resolveImplicitTxEngines() implicitTxEngines {
1✔
1744
        engine := e.storage
1✔
1745
        visited := make(map[storage.Engine]bool)
1✔
1746
        out := implicitTxEngines{}
1✔
1747

1✔
1748
        for engine != nil && !visited[engine] {
2✔
1749
                visited[engine] = true
1✔
1750

1✔
1751
                if out.namespace == "" {
2✔
1752
                        if ns, ok := engine.(interface{ Namespace() string }); ok {
2✔
1753
                                out.namespace = ns.Namespace()
1✔
1754
                        }
1✔
1755
                }
1756
                if out.asyncEngine == nil {
2✔
1757
                        if ae, ok := engine.(*storage.AsyncEngine); ok {
2✔
1758
                                out.asyncEngine = ae
1✔
1759
                        }
1✔
1760
                }
1761
                if out.txEngine == nil {
2✔
1762
                        if tc, ok := engine.(TransactionCapableEngine); ok {
2✔
1763
                                out.txEngine = tc
1✔
1764
                        }
1✔
1765
                }
1766

1767
                switch wrapper := engine.(type) {
1✔
1768
                case interface{ GetUnderlying() storage.Engine }:
1✔
1769
                        engine = wrapper.GetUnderlying()
1✔
1770
                case interface{ GetEngine() storage.Engine }:
1✔
1771
                        engine = wrapper.GetEngine()
1✔
1772
                case interface{ GetInnerEngine() storage.Engine }:
1✔
1773
                        engine = wrapper.GetInnerEngine()
1✔
1774
                default:
1✔
1775
                        engine = nil
1✔
1776
                }
1777
        }
1778

1779
        return out
1✔
1780
}
1781

1782
func (e *StorageExecutor) tryAsyncCreateNodeBatch(ctx context.Context, cypher string) (*ExecuteResult, error, bool) {
1✔
1783
        upper := strings.ToUpper(strings.TrimSpace(cypher))
1✔
1784
        if !strings.HasPrefix(upper, "CREATE") {
2✔
1785
                return nil, nil, false
1✔
1786
        }
1✔
1787
        // System commands and schema commands must not be handled here — route to executeSchemaCommand instead
1788
        if findMultiWordKeywordIndex(cypher, "CREATE", "DATABASE") == 0 ||
1✔
1789
                findMultiWordKeywordIndex(cypher, "CREATE", "COMPOSITE DATABASE") == 0 ||
1✔
1790
                findMultiWordKeywordIndex(cypher, "CREATE", "ALIAS") == 0 ||
1✔
1791
                findMultiWordKeywordIndex(cypher, "CREATE", "CONSTRAINT") == 0 ||
1✔
1792
                findMultiWordKeywordIndex(cypher, "CREATE", "INDEX") == 0 ||
1✔
1793
                findMultiWordKeywordIndex(cypher, "CREATE", "FULLTEXT") == 0 ||
1✔
1794
                findMultiWordKeywordIndex(cypher, "CREATE", "VECTOR") == 0 ||
1✔
1795
                findMultiWordKeywordIndex(cypher, "CREATE", "RANGE") == 0 {
2✔
1796
                return nil, nil, false
1✔
1797
        }
1✔
1798
        for _, keyword := range []string{
1✔
1799
                "MATCH",
1✔
1800
                "MERGE",
1✔
1801
                "SET",
1✔
1802
                "DELETE",
1✔
1803
                "DETACH",
1✔
1804
                "REMOVE",
1✔
1805
                "WITH",
1✔
1806
                "CALL",
1✔
1807
                "UNWIND",
1✔
1808
                "FOREACH",
1✔
1809
                "LOAD",
1✔
1810
                "OPTIONAL",
1✔
1811
        } {
2✔
1812
                if containsKeywordOutsideStrings(cypher, keyword) {
2✔
1813
                        return nil, nil, false
1✔
1814
                }
1✔
1815
        }
1816

1817
        // Substitute parameters before parsing so (n:Label $props) becomes (n:Label { ... })
1818
        // and the label is not mis-parsed as "Label $props".
1819
        if params := getParamsFromContext(ctx); params != nil {
1✔
1820
                cypher = e.substituteParams(cypher, params)
×
1821
        }
×
1822

1823
        returnIdx := findKeywordIndex(cypher, "RETURN")
1✔
1824
        createPart := cypher
1✔
1825
        if returnIdx > 0 {
2✔
1826
                createPart = strings.TrimSpace(cypher[:returnIdx])
1✔
1827
        }
1✔
1828

1829
        createClauses := SplitByCreate(createPart)
1✔
1830
        if len(createClauses) == 0 {
1✔
1831
                return nil, nil, false
×
1832
        }
×
1833

1834
        var nodePatterns []string
1✔
1835
        for _, clause := range createClauses {
2✔
1836
                clause = strings.TrimSpace(clause)
1✔
1837
                if clause == "" {
2✔
1838
                        continue
1✔
1839
                }
1840
                patterns := e.splitCreatePatterns(clause)
1✔
1841
                for _, pat := range patterns {
2✔
1842
                        pat = strings.TrimSpace(pat)
1✔
1843
                        if pat == "" {
1✔
1844
                                continue
×
1845
                        }
1846
                        if containsOutsideStrings(pat, "->") ||
1✔
1847
                                containsOutsideStrings(pat, "<-") ||
1✔
1848
                                containsOutsideStrings(pat, "]-") ||
1✔
1849
                                containsOutsideStrings(pat, "-[") {
2✔
1850
                                return nil, nil, false
1✔
1851
                        }
1✔
1852
                        nodePatterns = append(nodePatterns, pat)
1✔
1853
                }
1854
        }
1855

1856
        if len(nodePatterns) == 0 {
1✔
1857
                return nil, nil, false
×
1858
        }
×
1859

1860
        result := &ExecuteResult{
1✔
1861
                Columns: []string{},
1✔
1862
                Rows:    [][]interface{}{},
1✔
1863
                Stats:   &QueryStats{},
1✔
1864
        }
1✔
1865

1✔
1866
        createdNodes := make(map[string]*storage.Node)
1✔
1867
        nodes := make([]*storage.Node, 0, len(nodePatterns))
1✔
1868
        for _, nodePatternStr := range nodePatterns {
2✔
1869
                nodePattern := e.parseNodePattern(ctx, nodePatternStr)
1✔
1870

1✔
1871
                for _, label := range nodePattern.labels {
2✔
1872
                        if !isValidIdentifier(label) {
2✔
1873
                                return nil, fmt.Errorf("invalid label name: %q (must be alphanumeric starting with letter or underscore)", label), true
1✔
1874
                        }
1✔
1875
                        if containsReservedKeyword(label) {
1✔
1876
                                return nil, fmt.Errorf("invalid label name: %q (contains reserved keyword)", label), true
×
1877
                        }
×
1878
                }
1879

1880
                for key, val := range nodePattern.properties {
2✔
1881
                        if !isValidIdentifier(key) {
1✔
1882
                                return nil, fmt.Errorf("invalid property key: %q (must be alphanumeric starting with letter or underscore)", key), true
×
1883
                        }
×
1884
                        if _, ok := val.(invalidPropertyValue); ok {
1✔
1885
                                return nil, fmt.Errorf("invalid property value for key %q: malformed syntax", key), true
×
1886
                        }
×
1887
                }
1888

1889
                node := &storage.Node{
1✔
1890
                        ID:         storage.NodeID(e.generateID()),
1✔
1891
                        Labels:     nodePattern.labels,
1✔
1892
                        Properties: nodePattern.properties,
1✔
1893
                }
1✔
1894
                nodes = append(nodes, node)
1✔
1895
                if nodePattern.variable != "" {
2✔
1896
                        createdNodes[nodePattern.variable] = node
1✔
1897
                }
1✔
1898
        }
1899

1900
        store := e.getStorage(ctx)
1✔
1901
        if err := store.BulkCreateNodes(nodes); err != nil {
1✔
1902
                return nil, err, true
×
1903
        }
×
1904

1905
        for _, node := range nodes {
2✔
1906
                e.notifyNodeMutated(string(node.ID))
1✔
1907
                addOptimisticNodeID(result, node.ID)
1✔
1908
        }
1✔
1909
        result.Stats.NodesCreated += len(nodes)
1✔
1910

1✔
1911
        if returnIdx > 0 {
2✔
1912
                returnPart := strings.TrimSpace(cypher[returnIdx+6:])
1✔
1913
                returnItems := e.parseReturnItems(returnPart)
1✔
1914

1✔
1915
                result.Columns = make([]string, len(returnItems))
1✔
1916
                row := make([]interface{}, len(returnItems))
1✔
1917
                for i, item := range returnItems {
2✔
1918
                        if item.alias != "" {
2✔
1919
                                result.Columns[i] = item.alias
1✔
1920
                        } else {
2✔
1921
                                result.Columns[i] = item.expr
1✔
1922
                        }
1✔
1923

1924
                        for variable, node := range createdNodes {
2✔
1925
                                if strings.HasPrefix(item.expr, variable) || item.expr == variable {
2✔
1926
                                        row[i] = e.resolveReturnItem(ctx, item, variable, node)
1✔
1927
                                        break
1✔
1928
                                }
1929
                        }
1930

1931
                        if row[i] == nil {
1✔
1932
                                if varName := extractVariableNameFromReturnItem(item.expr); varName != "" {
×
1933
                                        if node, ok := createdNodes[varName]; ok {
×
1934
                                                row[i] = e.resolveReturnItem(ctx, item, varName, node)
×
1935
                                        }
×
1936
                                }
1937
                        }
1938
                }
1939
                result.Rows = [][]interface{}{row}
1✔
1940
        }
1941

1942
        return result, nil, true
1✔
1943
}
1944

1945
func (e *StorageExecutor) isEventualAsyncEligible(info *QueryInfo, cypher string) bool {
1✔
1946
        if info == nil || !info.IsWriteQuery {
1✔
1947
                return false
×
1948
        }
×
1949
        if info.HasSchema || info.IsSchemaQuery || isSystemCommandNoGraph(cypher) || isCreateProcedureCommand(cypher) {
2✔
1950
                return false
1✔
1951
        }
1✔
1952
        if info.FirstClause != ClauseCreate || !info.HasCreate {
2✔
1953
                return false
1✔
1954
        }
1✔
1955
        if info.HasMatch || info.HasOptionalMatch || info.HasMerge || info.HasDelete || info.HasDetachDelete ||
1✔
1956
                info.HasSet || info.HasRemove || info.HasWith || info.HasUnwind || info.HasCall ||
1✔
1957
                info.HasForeach || info.HasLoadCSV || info.HasUnion {
2✔
1958
                return false
1✔
1959
        }
1✔
1960
        return true
1✔
1961
}
1962

1963
// executeImplicitAsync executes a single query using implicit transactions for writes.
1964
// For write operations, wraps execution in an implicit transaction that can be
1965
// rolled back on error, preventing partial data corruption from failed queries.
1966
// For strict ACID guarantees with durability, use explicit BEGIN/COMMIT transactions.
1967
func (e *StorageExecutor) executeImplicitAsync(ctx context.Context, cypher string, upperQuery string) (*ExecuteResult, error) {
1✔
1968
        // Check if this is a write operation using cached analysis
1✔
1969
        info := e.analyzer.Analyze(cypher)
1✔
1970
        isWrite := info.IsWriteQuery
1✔
1971

1✔
1972
        // For write operations, use implicit transaction for atomicity
1✔
1973
        // This ensures partial writes are rolled back on error
1✔
1974
        if isWrite {
2✔
1975
                if hasCallInTransactions(cypher) {
2✔
1976
                        return e.executeWithoutTransaction(ctx, cypher, upperQuery)
1✔
1977
                }
1✔
1978
                engines := e.resolveImplicitTxEngines()
1✔
1979
                if engines.asyncEngine != nil {
2✔
1980
                        if result, err, handled := e.tryAsyncCreateNodeBatch(ctx, cypher); handled {
2✔
1981
                                return result, err
1✔
1982
                        }
1✔
1983
                        if e.isEventualAsyncEligible(info, cypher) {
2✔
1984
                                return e.executeWithoutTransaction(ctx, cypher, upperQuery)
1✔
1985
                        }
1✔
1986
                }
1987
                return e.executeWithImplicitTransaction(ctx, cypher, upperQuery)
1✔
1988
        }
1989

1990
        // Read-only operations don't need transaction wrapping
1991
        return e.executeWithoutTransaction(ctx, cypher, upperQuery)
1✔
1992
}
1993

1994
// executeWithImplicitTransaction wraps a write query in a single implicit
1995
// transaction. Commit-time conflicts are returned to the caller; retry-aware
1996
// clients own any replay decision because NornicDB does not know whether a
1997
// conflict is recoverable for the application.
1998
func (e *StorageExecutor) executeWithImplicitTransaction(ctx context.Context, cypher string, upperQuery string) (*ExecuteResult, error) {
1✔
1999
        parsedCypher, inlineEmbeddingEnabled := stripWithEmbeddingSuffix(cypher)
1✔
2000
        if inlineEmbeddingEnabled {
2✔
2001
                cypher = parsedCypher
1✔
2002
                upperQuery = strings.ToUpper(cypher)
1✔
2003
        }
1✔
2004

2005
        // Try to get a transaction-capable engine and async wrapper (if present)
2006
        engines := e.resolveImplicitTxEngines()
1✔
2007
        if engines.namespace == "" {
2✔
2008
                if dbName := strings.TrimSpace(GetUseDatabaseFromContext(ctx)); dbName != "" {
1✔
2009
                        engines.namespace = dbName
×
2010
                } else if _, dbName := e.resolveWALAndDatabase(); strings.TrimSpace(dbName) != "" {
1✔
2011
                        engines.namespace = strings.TrimSpace(dbName)
×
2012
                }
×
2013
        }
2014
        txEngine := engines.txEngine
1✔
2015
        asyncEngine := engines.asyncEngine
1✔
2016

1✔
2017
        // If no transaction support, fall back to direct execution (legacy mode)
1✔
2018
        // This is less safe but maintains backward compatibility
1✔
2019
        if txEngine == nil {
2✔
2020
                if inlineEmbeddingEnabled {
1✔
2021
                        return nil, fmt.Errorf("WITH EMBEDDING requires transaction-capable storage")
×
2022
                }
×
2023
                result, err := e.executeWithoutTransaction(ctx, cypher, upperQuery)
1✔
2024
                if err != nil {
2✔
2025
                        return nil, err
1✔
2026
                }
1✔
2027
                // Flush if needed
2028
                if !e.deferFlush {
2✔
2029
                        if asyncEngine != nil {
1✔
2030
                                asyncEngine.Flush()
×
2031
                        }
×
2032
                }
2033
                return result, nil
1✔
2034
        }
2035

2036
        // IMPORTANT: If using AsyncEngine with pending writes, flush its cache BEFORE
2037
        // starting the transaction. This ensures the BadgerTransaction can see all
2038
        // previously written data. Without this, MATCH queries in compound statements
2039
        // (MATCH...CREATE) would fail to find nodes in AsyncEngine's cache.
2040
        // We use HasPendingWrites() first as a cheap check to avoid unnecessary flushes.
2041
        if asyncEngine != nil && asyncEngine.HasPendingWrites() {
2✔
2042
                asyncEngine.Flush()
1✔
2043
        }
1✔
2044
        releaseAsyncFlushHold := func() {}
1✔
2045
        if asyncEngine != nil {
2✔
2046
                release := asyncEngine.HoldFlush()
1✔
2047
                held := true
1✔
2048
                releaseAsyncFlushHold = func() {
2✔
2049
                        if held {
2✔
2050
                                release()
1✔
2051
                                held = false
1✔
2052
                        }
1✔
2053
                }
2054
                defer releaseAsyncFlushHold()
1✔
2055
        }
2056

2057
        // Start implicit transaction
2058
        if engines.namespace != "" {
2✔
2059
                if primer, ok := txEngine.(interface{ EnsureNamespaceMVCC(string) error }); ok {
2✔
2060
                        if err := primer.EnsureNamespaceMVCC(engines.namespace); err != nil {
1✔
2061
                                return nil, fmt.Errorf("failed to prime implicit transaction namespace: %w", err)
×
2062
                        }
×
2063
                }
2064
        }
2065
        tx, err := txEngine.BeginTransaction()
1✔
2066
        if err != nil {
2✔
2067
                return nil, fmt.Errorf("failed to start implicit transaction: %w", err)
1✔
2068
        }
1✔
2069
        if engines.namespace != "" {
2✔
2070
                if err := tx.SetNamespace(engines.namespace); err != nil {
1✔
2071
                        _ = tx.Rollback()
×
2072
                        return nil, fmt.Errorf("failed to pin implicit transaction namespace: %w", err)
×
2073
                }
×
2074
        }
2075

2076
        // Defer constraint validation to commit for implicit transactions.
2077
        // This avoids duplicate per-operation checks and improves write throughput.
2078
        if err := tx.SetDeferredConstraintValidation(true); err != nil {
1✔
2079
                _ = tx.Rollback()
×
2080
                return nil, fmt.Errorf("failed to configure implicit transaction: %w", err)
×
2081
        }
×
2082
        if err := tx.SetSkipCreateExistenceCheck(true); err != nil {
1✔
2083
                _ = tx.Rollback()
×
2084
                return nil, fmt.Errorf("failed to configure implicit transaction: %w", err)
×
2085
        }
×
2086
        // Skip the per-commit engine.Sync(). The Bolt session's end-of-session
2087
        // Flush and the async engine's ticker-driven flush coalesce durability
2088
        // for implicit writes; forcing an Msync per UNWIND batch turned every
2089
        // batch into a 300µs syscall for no user-visible benefit.
2090
        if err := tx.SetImplicit(true); err != nil {
1✔
2091
                _ = tx.Rollback()
×
2092
                return nil, fmt.Errorf("failed to configure implicit transaction: %w", err)
×
2093
        }
×
2094

2095
        // Optional WAL transaction markers for receipts.
2096
        var wal *storage.WAL
1✔
2097
        var walSeqStart uint64
1✔
2098
        txID := tx.ID
1✔
2099
        var dbName string
1✔
2100
        if txID != "" {
2✔
2101
                wal, dbName = e.resolveWALAndDatabase()
1✔
2102
                if wal != nil {
2✔
2103
                        walSeqStart, err = wal.AppendTxBegin(dbName, txID, nil)
1✔
2104
                        if err != nil {
2✔
2105
                                _ = tx.Rollback()
1✔
2106
                                return nil, fmt.Errorf("failed to write WAL tx begin: %w", err)
1✔
2107
                        }
1✔
2108
                }
2109
        }
2110

2111
        // Create a transactional wrapper that routes writes through the transaction
2112
        // CRITICAL: We pass the wrapper through context instead of modifying e.storage
2113
        // because e.storage modification is NOT thread-safe for concurrent executions.
2114
        separator := ":"
1✔
2115
        if engines.namespace == "" {
2✔
2116
                separator = ""
1✔
2117
        }
1✔
2118
        txWrapper := &transactionStorageWrapper{
1✔
2119
                tx:             tx,
1✔
2120
                underlying:     e.storage,
1✔
2121
                namespace:      engines.namespace,
1✔
2122
                separator:      separator,
1✔
2123
                mutatedNodeIDs: make(map[string]struct{}),
1✔
2124
        }
1✔
2125

1✔
2126
        // Execute with transaction wrapper via context
1✔
2127
        txCtx := context.WithValue(ctx, ctxKeyTxStorage, txWrapper)
1✔
2128
        txExec := e.cloneWithStorage(txWrapper)
1✔
2129

1✔
2130
        // Execute the query
1✔
2131
        result, execErr := txExec.executeWithoutTransaction(txCtx, cypher, upperQuery)
1✔
2132

1✔
2133
        // Handle result
1✔
2134
        if execErr != nil {
2✔
2135
                // Rollback on any error - prevents partial data corruption
1✔
2136
                tx.Rollback()
1✔
2137
                txExec.invalidateNodeLookupCache()
1✔
2138
                if wal != nil && walSeqStart > 0 {
1✔
2139
                        _, _ = wal.AppendTxAbort(dbName, txID, execErr.Error())
×
2140
                }
×
2141
                return nil, execErr
1✔
2142
        }
2143

2144
        if inlineEmbeddingEnabled {
2✔
2145
                if err := txExec.applyInlineEmbeddingMutations(txCtx, txWrapper.snapshotMutatedNodeIDs()); err != nil {
1✔
2146
                        tx.Rollback()
×
2147
                        txExec.invalidateNodeLookupCache()
×
2148
                        if wal != nil && walSeqStart > 0 {
×
2149
                                _, _ = wal.AppendTxAbort(dbName, txID, err.Error())
×
2150
                        }
×
2151
                        return nil, err
×
2152
                }
2153
        }
2154

2155
        // Commit successful transaction
2156
        if err := tx.Commit(); err != nil {
2✔
2157
                txExec.invalidateNodeLookupCache()
1✔
2158
                if wal != nil && walSeqStart > 0 {
1✔
2159
                        _, _ = wal.AppendTxAbort(dbName, txID, err.Error())
×
2160
                }
×
2161
                if info := e.analyzer.Analyze(cypher); IsRetrySafeMergeCommitQuery(info) {
2✔
2162
                        err = nornicerrors.MarkMergeCommitTimeUniqueConflict(err)
1✔
2163
                }
1✔
2164
                // Wire contract: substring "commit failed" is matched by downstream Bolt classifiers.
2165
                // See docs/plans/consumer-pinned-error-contract-plan.md §2.1.
2166
                // The implicit-autocommit path was historically wrapped with "failed to commit
2167
                // implicit transaction: ..." which broke the consumer-pinned classifier; aligned
2168
                // with pkg/cypher/transaction.go:181 so the explicit and implicit paths produce the
2169
                // same wire shape.
2170
                return nil, fmt.Errorf("commit failed: %w", err)
1✔
2171
        }
2172

2173
        // Attach receipt metadata if WAL markers were recorded.
2174
        if wal != nil && walSeqStart > 0 {
2✔
2175
                opCount := tx.OperationCount()
1✔
2176
                if commitSeq, walErr := wal.AppendTxCommit(dbName, txID, opCount); walErr == nil {
2✔
2177
                        if receipt, recErr := storage.NewReceipt(txID, walSeqStart, commitSeq, dbName, time.Now().UTC()); recErr == nil {
2✔
2178
                                if result.Metadata == nil {
2✔
2179
                                        result.Metadata = make(map[string]interface{})
1✔
2180
                                }
1✔
2181
                                result.Metadata["receipt"] = receipt
1✔
2182
                        }
2183
                }
2184
        }
2185

2186
        // Promote the tx-scoped MERGE lookup cache into the parent so
2187
        // subsequent Execute calls still benefit from the cross-query
2188
        // speedup. Tx isolation is preserved because each in-flight tx had
2189
        // its own clone; only post-commit entries graduate to the parent.
2190
        txExec.promoteNodeLookupCacheTo(e)
1✔
2191

1✔
2192
        // Flush if needed for durability
1✔
2193
        if !e.deferFlush && asyncEngine != nil {
2✔
2194
                releaseAsyncFlushHold()
1✔
2195
                asyncEngine.Flush()
1✔
2196
        }
1✔
2197

2198
        return result, nil
1✔
2199
}
2200

2201
// ctxKeyTxStorage is the context key for transaction storage wrapper.
2202
type ctxKeyTxStorageType struct{}
2203

2204
var ctxKeyTxStorage = ctxKeyTxStorageType{}
2205

2206
func (e *StorageExecutor) applyInlineEmbeddingMutations(ctx context.Context, ids map[string]struct{}) error {
1✔
2207
        if len(ids) == 0 {
2✔
2208
                return nil
1✔
2209
        }
1✔
2210
        if e.embedder == nil {
2✔
2211
                return fmt.Errorf("WITH EMBEDDING requires configured embedder")
1✔
2212
        }
1✔
2213
        store := e.getStorage(ctx)
1✔
2214
        for id := range ids {
2✔
2215
                node, err := store.GetNode(storage.NodeID(id))
1✔
2216
                if err != nil {
2✔
2217
                        if err == storage.ErrNotFound {
2✔
2218
                                continue
1✔
2219
                        }
2220
                        return err
1✔
2221
                }
2222
                if node == nil {
1✔
2223
                        continue
×
2224
                }
2225
                text := embeddingutil.BuildText(node.Properties, node.Labels, e.inlineEmbeddingTextOptions)
1✔
2226
                chunks, err := e.embedder.ChunkText(text, e.inlineEmbeddingChunkSize, e.inlineEmbeddingChunkOverlap)
1✔
2227
                if err != nil {
2✔
2228
                        return fmt.Errorf("WITH EMBEDDING chunking failed for node %s: %w", id, err)
1✔
2229
                }
1✔
2230
                if len(chunks) == 0 {
1✔
2231
                        chunks = []string{text}
×
2232
                }
×
2233
                embeddings := make([][]float32, 0, len(chunks))
1✔
2234
                for _, chunk := range chunks {
2✔
2235
                        emb, err := e.embedder.Embed(ctx, chunk)
1✔
2236
                        if err != nil {
2✔
2237
                                return fmt.Errorf("WITH EMBEDDING embed failed for node %s: %w", id, err)
1✔
2238
                        }
1✔
2239
                        if len(emb) == 0 {
2✔
2240
                                return fmt.Errorf("WITH EMBEDDING embed returned empty vector for node %s", id)
1✔
2241
                        }
1✔
2242
                        embeddings = append(embeddings, emb)
1✔
2243
                }
2244
                model := "inline-cypher"
1✔
2245
                dimensions := len(embeddings[0])
1✔
2246
                if named, ok := e.embedder.(interface{ Model() string }); ok {
2✔
2247
                        if v := strings.TrimSpace(named.Model()); v != "" {
2✔
2248
                                model = v
1✔
2249
                        }
1✔
2250
                }
2251
                if d, ok := e.embedder.(interface{ Dimensions() int }); ok {
2✔
2252
                        if v := d.Dimensions(); v > 0 {
2✔
2253
                                dimensions = v
1✔
2254
                        }
1✔
2255
                }
2256
                embeddingutil.ApplyManagedEmbedding(node, embeddings, model, dimensions, time.Now())
1✔
2257
                if err := store.UpdateNode(node); err != nil {
2✔
2258
                        return err
1✔
2259
                }
1✔
2260
        }
2261
        return nil
1✔
2262
}
2263

2264
func stripWithEmbeddingSuffix(cypher string) (string, bool) {
1✔
2265
        idx := findKeywordIndex(cypher, "WITH EMBEDDING")
1✔
2266
        if idx < 0 {
2✔
2267
                return cypher, false
1✔
2268
        }
1✔
2269
        before := strings.TrimSpace(cypher[:idx])
1✔
2270
        after := strings.TrimSpace(cypher[idx+len("WITH EMBEDDING"):])
1✔
2271
        if before == "" {
2✔
2272
                return cypher, false
1✔
2273
        }
1✔
2274
        if after == "" {
2✔
2275
                return before, true
1✔
2276
        }
1✔
2277
        return strings.TrimSpace(before + " " + after), true
1✔
2278
}
2279

2280
func maxInt(a, b int) int {
1✔
2281
        if a > b {
2✔
2282
                return a
1✔
2283
        }
1✔
2284
        return b
×
2285
}
2286

2287
// ctxKeyUseDatabase is the context key for :USE database switching.
2288
// When :USE database_name is detected, the database name is stored in context
2289
// so the server can switch to that database before executing the query.
2290
type ctxKeyUseDatabaseType struct{}
2291

2292
var ctxKeyUseDatabase = ctxKeyUseDatabaseType{}
2293

2294
// ctxKeyAuthToken carries an Authorization header value for remote/OIDC forwarding.
2295
type ctxKeyAuthTokenType struct{}
2296

2297
var ctxKeyAuthToken = ctxKeyAuthTokenType{}
2298

2299
// GetUseDatabaseFromContext extracts the database name from :USE command if present in context.
2300
// Returns empty string if no :USE command was found.
2301
func GetUseDatabaseFromContext(ctx context.Context) string {
1✔
2302
        if dbName, ok := ctx.Value(ctxKeyUseDatabase).(string); ok {
2✔
2303
                return dbName
1✔
2304
        }
1✔
2305
        return ""
1✔
2306
}
2307

2308
// WithAuthToken stores an Authorization header token on context for execution paths
2309
// that need to forward caller identity across remote constituents.
2310
func WithAuthToken(ctx context.Context, authToken string) context.Context {
1✔
2311
        if strings.TrimSpace(authToken) == "" {
2✔
2312
                return ctx
1✔
2313
        }
1✔
2314
        return context.WithValue(ctx, ctxKeyAuthToken, authToken)
1✔
2315
}
2316

2317
// GetAuthTokenFromContext extracts forwarded Authorization token from context.
2318
func GetAuthTokenFromContext(ctx context.Context) string {
1✔
2319
        if v, ok := ctx.Value(ctxKeyAuthToken).(string); ok {
2✔
2320
                return v
1✔
2321
        }
1✔
2322
        return ""
1✔
2323
}
2324

2325
// getStorage returns the storage to use for the current execution.
2326
// If a transaction wrapper is present in context, it uses that; otherwise uses e.storage.
2327
func (e *StorageExecutor) getStorage(ctx context.Context) storage.Engine {
1✔
2328
        if txWrapper, ok := ctx.Value(ctxKeyTxStorage).(*transactionStorageWrapper); ok {
2✔
2329
                return txWrapper
1✔
2330
        }
1✔
2331
        return e.storage
1✔
2332
}
2333

2334
// resolveWALAndDatabase attempts to find a WAL instance and database name
2335
// by unwrapping common storage wrappers (namespaced, async, WAL engines).
2336
func (e *StorageExecutor) resolveWALAndDatabase() (*storage.WAL, string) {
1✔
2337
        engine := e.storage
1✔
2338
        var dbName string
1✔
2339

1✔
2340
        for engine != nil {
2✔
2341
                if ns, ok := engine.(interface{ Namespace() string }); ok && dbName == "" {
2✔
2342
                        dbName = ns.Namespace()
1✔
2343
                }
1✔
2344
                if walProvider, ok := engine.(interface{ GetWAL() *storage.WAL }); ok {
2✔
2345
                        return walProvider.GetWAL(), dbName
1✔
2346
                }
1✔
2347
                switch wrapper := engine.(type) {
1✔
2348
                case interface{ GetUnderlying() storage.Engine }:
1✔
2349
                        engine = wrapper.GetUnderlying()
1✔
2350
                case interface{ GetEngine() storage.Engine }:
1✔
2351
                        engine = wrapper.GetEngine()
1✔
2352
                case interface{ GetInnerEngine() storage.Engine }:
1✔
2353
                        engine = wrapper.GetInnerEngine()
1✔
2354
                default:
1✔
2355
                        return nil, dbName
1✔
2356
                }
2357
        }
2358

2359
        return nil, dbName
×
2360
}
2361

2362
// transactionStorageWrapper wraps a BadgerTransaction to implement storage.Engine
2363
// for use in implicit transaction execution. It routes writes through the transaction
2364
// (for atomicity/rollback) and reads through the underlying engine (for performance).
2365
type transactionStorageWrapper struct {
2366
        tx               *storage.BadgerTransaction
2367
        underlying       storage.Engine // For read operations not supported by transaction
2368
        namespace        string
2369
        separator        string
2370
        mutatedNodeIDs   map[string]struct{}
2371
        mutatedNodeIDsMu sync.Mutex
2372

2373
        // txNodeLookupCache scopes the executor's MERGE/MATCH lookup cache to
2374
        // this single transaction. Concurrent transactions get distinct
2375
        // wrappers — and therefore distinct caches — so a peer's uncommitted
2376
        // node-ID mapping cannot leak into this transaction's read set via
2377
        // store.GetNode(...) inside tx.badgerTx (which would otherwise put
2378
        // the peer's node key into Badger's SSI read set and convert a
2379
        // constraint violation into a generic Transaction Conflict). Within
2380
        // a single transaction the cache is shared across re-entries that
2381
        // reuse the same wrapper, so multi-clause queries still benefit
2382
        // from the cross-clause speedup.
2383
        txNodeLookupCache   map[string]*storage.Node
2384
        txNodeLookupCacheMu *sync.RWMutex
2385
}
2386

2387
func (w *transactionStorageWrapper) Namespace() string {
1✔
2388
        if w == nil {
1✔
2389
                return ""
×
2390
        }
×
2391
        return w.namespace
1✔
2392
}
2393

2394
// ensureNodeLookupCacheLocked lazily initializes the wrapper's MERGE/MATCH
2395
// lookup cache, seeding it once from the parent executor's committed
2396
// entries. The cache exists for the lifetime of the transaction; on
2397
// commit, executor code drains it back into the parent executor via
2398
// promoteNodeLookupCacheTo, on rollback the wrapper is discarded and the
2399
// cache with it. Subsequent calls (e.g. recursive Execute re-entry on
2400
// the same wrapper) are no-ops, so the in-tx state survives across
2401
// multi-clause queries.
2402
func (w *transactionStorageWrapper) ensureNodeLookupCacheLocked(seedFrom *StorageExecutor) {
1✔
2403
        if w.txNodeLookupCacheMu != nil && w.txNodeLookupCache != nil {
2✔
2404
                return
1✔
2405
        }
1✔
2406
        w.txNodeLookupCacheMu = &sync.RWMutex{}
1✔
2407
        w.txNodeLookupCache = make(map[string]*storage.Node, 1000)
1✔
2408
        if seedFrom == nil {
1✔
2409
                return
×
2410
        }
×
2411
        srcMu := seedFrom.nodeLookupCacheLock()
1✔
2412
        srcMu.RLock()
1✔
2413
        for k, v := range seedFrom.nodeLookupCache {
2✔
2414
                w.txNodeLookupCache[k] = v
1✔
2415
        }
1✔
2416
        srcMu.RUnlock()
1✔
2417
}
2418

2419
func (w *transactionStorageWrapper) GetEngine() storage.Engine {
1✔
2420
        if w == nil {
1✔
2421
                return nil
×
2422
        }
×
2423
        return w.underlying
1✔
2424
}
2425

2426
func (w *transactionStorageWrapper) markMutatedNodeID(id storage.NodeID) {
1✔
2427
        if id == "" || w.mutatedNodeIDs == nil {
2✔
2428
                return
1✔
2429
        }
1✔
2430
        w.mutatedNodeIDsMu.Lock()
1✔
2431
        w.mutatedNodeIDs[string(id)] = struct{}{}
1✔
2432
        w.mutatedNodeIDsMu.Unlock()
1✔
2433
}
2434

2435
func (w *transactionStorageWrapper) snapshotMutatedNodeIDs() map[string]struct{} {
1✔
2436
        if len(w.mutatedNodeIDs) == 0 {
2✔
2437
                return nil
1✔
2438
        }
1✔
2439
        w.mutatedNodeIDsMu.Lock()
1✔
2440
        defer w.mutatedNodeIDsMu.Unlock()
1✔
2441
        out := make(map[string]struct{}, len(w.mutatedNodeIDs))
1✔
2442
        for id := range w.mutatedNodeIDs {
2✔
2443
                out[id] = struct{}{}
1✔
2444
        }
1✔
2445
        return out
1✔
2446
}
2447

2448
// Write operations - go through transaction for atomicity
2449
func (w *transactionStorageWrapper) CreateNode(node *storage.Node) (storage.NodeID, error) {
1✔
2450
        if w.namespace == "" {
2✔
2451
                id, err := w.tx.CreateNode(node)
1✔
2452
                if err == nil {
1✔
2453
                        w.markMutatedNodeID(id)
×
2454
                }
×
2455
                return id, err
1✔
2456
        }
2457
        namespaced := storage.CopyNode(node)
1✔
2458
        namespaced.ID = w.prefixNodeID(node.ID)
1✔
2459
        actualID, err := w.tx.CreateNode(namespaced)
1✔
2460
        if err != nil {
1✔
2461
                return "", err
×
2462
        }
×
2463
        userID := w.unprefixNodeID(actualID)
1✔
2464
        w.markMutatedNodeID(userID)
1✔
2465
        return userID, nil
1✔
2466
}
2467

2468
func (w *transactionStorageWrapper) UpdateNode(node *storage.Node) error {
1✔
2469
        if w.namespace == "" {
2✔
2470
                err := w.tx.UpdateNode(node)
1✔
2471
                if err == nil {
2✔
2472
                        w.markMutatedNodeID(node.ID)
1✔
2473
                }
1✔
2474
                return err
1✔
2475
        }
2476
        namespaced := storage.CopyNode(node)
1✔
2477
        namespaced.ID = w.prefixNodeID(node.ID)
1✔
2478
        err := w.tx.UpdateNode(namespaced)
1✔
2479
        if err == nil {
2✔
2480
                w.markMutatedNodeID(node.ID)
1✔
2481
        }
1✔
2482
        return err
1✔
2483
}
2484

2485
func (w *transactionStorageWrapper) DeleteNode(id storage.NodeID) error {
1✔
2486
        return w.tx.DeleteNode(w.prefixNodeID(id))
1✔
2487
}
1✔
2488

2489
func (w *transactionStorageWrapper) CreateEdge(edge *storage.Edge) error {
1✔
2490
        if w.namespace == "" {
1✔
2491
                return w.tx.CreateEdge(edge)
×
2492
        }
×
2493
        namespaced := storage.CopyEdge(edge)
1✔
2494
        namespaced.ID = w.prefixEdgeID(edge.ID)
1✔
2495
        namespaced.StartNode = w.prefixNodeID(edge.StartNode)
1✔
2496
        namespaced.EndNode = w.prefixNodeID(edge.EndNode)
1✔
2497
        return w.tx.CreateEdge(namespaced)
1✔
2498
}
2499

2500
func (w *transactionStorageWrapper) DeleteEdge(id storage.EdgeID) error {
1✔
2501
        return w.tx.DeleteEdge(w.prefixEdgeID(id))
1✔
2502
}
1✔
2503

2504
// Read operations - transaction supports GetNode, forward others to underlying
2505
func (w *transactionStorageWrapper) GetNode(id storage.NodeID) (*storage.Node, error) {
1✔
2506
        node, err := w.tx.GetNode(w.prefixNodeID(id))
1✔
2507
        if err != nil {
2✔
2508
                return nil, err
1✔
2509
        }
1✔
2510
        if w.namespace == "" {
2✔
2511
                return node, nil
1✔
2512
        }
1✔
2513
        return w.toUserNode(node), nil
1✔
2514
}
2515

2516
func (w *transactionStorageWrapper) GetEdge(id storage.EdgeID) (*storage.Edge, error) {
1✔
2517
        if w.namespace == "" {
1✔
2518
                return w.tx.GetEdge(id)
×
2519
        }
×
2520
        edge, err := w.tx.GetEdge(w.prefixEdgeID(id))
1✔
2521
        if err != nil {
2✔
2522
                return nil, err
1✔
2523
        }
1✔
2524
        return w.toUserEdge(edge), nil
1✔
2525
}
2526

2527
func (w *transactionStorageWrapper) UpdateEdge(edge *storage.Edge) error {
1✔
2528
        if w.namespace == "" {
1✔
2529
                return w.tx.UpdateEdge(edge)
×
2530
        }
×
2531
        namespaced := storage.CopyEdge(edge)
1✔
2532
        namespaced.ID = w.prefixEdgeID(edge.ID)
1✔
2533
        namespaced.StartNode = w.prefixNodeID(edge.StartNode)
1✔
2534
        namespaced.EndNode = w.prefixNodeID(edge.EndNode)
1✔
2535
        return w.tx.UpdateEdge(namespaced)
1✔
2536
}
2537

2538
func (w *transactionStorageWrapper) GetNodesByLabel(label string) ([]*storage.Node, error) {
1✔
2539
        nodes, err := w.tx.GetNodesByLabel(label)
1✔
2540
        if err != nil {
1✔
2541
                return nil, err
×
2542
        }
×
2543
        if w.namespace == "" {
2✔
2544
                return nodes, nil
1✔
2545
        }
1✔
2546
        return w.toUserNodes(nodes), nil
1✔
2547
}
2548

2549
func (w *transactionStorageWrapper) GetFirstNodeByLabel(label string) (*storage.Node, error) {
1✔
2550
        node, err := w.tx.GetFirstNodeByLabel(label)
1✔
2551
        if err != nil {
1✔
2552
                return nil, err
×
2553
        }
×
2554
        if w.namespace == "" {
2✔
2555
                return node, nil
1✔
2556
        }
1✔
2557
        return w.toUserNode(node), nil
×
2558
}
2559

2560
func (w *transactionStorageWrapper) ForEachNodeIDByLabel(label string, visit func(storage.NodeID) bool) error {
1✔
2561
        if visit == nil {
1✔
2562
                return nil
×
2563
        }
×
2564
        if !w.tx.HasPendingNodeMutations() {
2✔
2565
                if lookup, ok := w.underlying.(storage.LabelNodeIDLookupEngine); ok {
2✔
2566
                        return lookup.ForEachNodeIDByLabel(label, visit)
1✔
2567
                }
1✔
2568
        }
2569

2570
        nodes, err := w.GetNodesByLabel(label)
×
2571
        if err != nil {
×
2572
                return err
×
2573
        }
×
2574
        for _, node := range nodes {
×
2575
                if node == nil {
×
2576
                        continue
×
2577
                }
2578
                if !visit(node.ID) {
×
2579
                        return nil
×
2580
                }
×
2581
        }
2582
        return nil
×
2583
}
2584

2585
func (w *transactionStorageWrapper) GetOutgoingEdges(nodeID storage.NodeID) ([]*storage.Edge, error) {
1✔
2586
        if w.namespace == "" {
1✔
2587
                return w.tx.GetOutgoingEdges(nodeID)
×
2588
        }
×
2589
        edges, err := w.tx.GetOutgoingEdges(w.prefixNodeID(nodeID))
1✔
2590
        if err != nil {
1✔
2591
                return nil, err
×
2592
        }
×
2593
        return w.toUserEdges(edges), nil
1✔
2594
}
2595

2596
func (w *transactionStorageWrapper) GetIncomingEdges(nodeID storage.NodeID) ([]*storage.Edge, error) {
1✔
2597
        if w.namespace == "" {
1✔
2598
                return w.tx.GetIncomingEdges(nodeID)
×
2599
        }
×
2600
        edges, err := w.tx.GetIncomingEdges(w.prefixNodeID(nodeID))
1✔
2601
        if err != nil {
1✔
2602
                return nil, err
×
2603
        }
×
2604
        return w.toUserEdges(edges), nil
1✔
2605
}
2606

2607
func (w *transactionStorageWrapper) GetEdgesBetween(startID, endID storage.NodeID) ([]*storage.Edge, error) {
1✔
2608
        if w.namespace == "" {
2✔
2609
                return w.tx.GetEdgesBetween(startID, endID)
1✔
2610
        }
1✔
2611
        edges, err := w.tx.GetEdgesBetween(w.prefixNodeID(startID), w.prefixNodeID(endID))
1✔
2612
        if err != nil {
1✔
2613
                return nil, err
×
2614
        }
×
2615
        return w.toUserEdges(edges), nil
1✔
2616
}
2617

2618
func (w *transactionStorageWrapper) GetEdgeBetween(startID, endID storage.NodeID, edgeType string) *storage.Edge {
1✔
2619
        if w.namespace == "" {
1✔
2620
                return w.tx.GetEdgeBetween(startID, endID, edgeType)
×
2621
        }
×
2622
        edge := w.tx.GetEdgeBetween(w.prefixNodeID(startID), w.prefixNodeID(endID), edgeType)
1✔
2623
        if edge == nil {
2✔
2624
                return nil
1✔
2625
        }
1✔
2626
        return w.toUserEdge(edge)
1✔
2627
}
2628

2629
func (w *transactionStorageWrapper) GetEdgesByType(edgeType string) ([]*storage.Edge, error) {
1✔
2630
        if w.namespace == "" {
2✔
2631
                return w.tx.GetEdgesByType(edgeType)
1✔
2632
        }
1✔
2633
        edges, err := w.tx.GetEdgesByType(edgeType)
1✔
2634
        if err != nil {
1✔
2635
                return nil, err
×
2636
        }
×
2637
        return w.toUserEdges(edges), nil
1✔
2638
}
2639

2640
func (w *transactionStorageWrapper) GetNodesByLabelVisibleAt(label string, version storage.MVCCVersion) ([]*storage.Node, error) {
1✔
2641
        if provider, ok := w.underlying.(storage.MVCCIndexedVisibilityEngine); ok {
2✔
2642
                return provider.GetNodesByLabelVisibleAt(label, version)
1✔
2643
        }
1✔
2644
        return nil, storage.ErrNotImplemented
1✔
2645
}
2646

2647
func (w *transactionStorageWrapper) GetOutgoingEdgesVisibleAt(nodeID storage.NodeID, version storage.MVCCVersion) ([]*storage.Edge, error) {
1✔
2648
        provider, ok := w.underlying.(storage.MVCCIndexedVisibilityEngine)
1✔
2649
        if !ok {
1✔
NEW
2650
                return nil, storage.ErrNotImplemented
×
NEW
2651
        }
×
2652
        edges, err := provider.GetOutgoingEdgesVisibleAt(w.prefixNodeID(nodeID), version)
1✔
2653
        if err != nil || w.namespace == "" {
1✔
NEW
2654
                return edges, err
×
NEW
2655
        }
×
2656
        return w.toUserEdges(edges), nil
1✔
2657
}
2658

2659
func (w *transactionStorageWrapper) GetIncomingEdgesVisibleAt(nodeID storage.NodeID, version storage.MVCCVersion) ([]*storage.Edge, error) {
1✔
2660
        provider, ok := w.underlying.(storage.MVCCIndexedVisibilityEngine)
1✔
2661
        if !ok {
1✔
NEW
2662
                return nil, storage.ErrNotImplemented
×
NEW
2663
        }
×
2664
        edges, err := provider.GetIncomingEdgesVisibleAt(w.prefixNodeID(nodeID), version)
1✔
2665
        if err != nil || w.namespace == "" {
1✔
NEW
2666
                return edges, err
×
NEW
2667
        }
×
2668
        return w.toUserEdges(edges), nil
1✔
2669
}
2670

2671
func (w *transactionStorageWrapper) GetEdgesByTypeVisibleAt(edgeType string, version storage.MVCCVersion) ([]*storage.Edge, error) {
1✔
2672
        if provider, ok := w.underlying.(storage.MVCCIndexedVisibilityEngine); ok {
2✔
2673
                return provider.GetEdgesByTypeVisibleAt(edgeType, version)
1✔
2674
        }
1✔
2675
        return nil, storage.ErrNotImplemented
1✔
2676
}
2677

2678
func (w *transactionStorageWrapper) GetEdgesBetweenVisibleAt(startID, endID storage.NodeID, version storage.MVCCVersion) ([]*storage.Edge, error) {
1✔
2679
        if provider, ok := w.underlying.(storage.MVCCIndexedVisibilityEngine); ok {
2✔
2680
                return provider.GetEdgesBetweenVisibleAt(startID, endID, version)
1✔
2681
        }
1✔
2682
        return nil, storage.ErrNotImplemented
1✔
2683
}
2684

2685
func (w *transactionStorageWrapper) AllNodes() ([]*storage.Node, error) {
1✔
2686
        nodes, err := w.tx.AllNodes()
1✔
2687
        if err != nil {
1✔
2688
                return nil, err
×
2689
        }
×
2690
        if w.namespace == "" {
2✔
2691
                return nodes, nil
1✔
2692
        }
1✔
2693
        return w.toUserNodes(nodes), nil
1✔
2694
}
2695

2696
func (w *transactionStorageWrapper) AllEdges() ([]*storage.Edge, error) {
1✔
2697
        return w.underlying.AllEdges()
1✔
2698
}
1✔
2699

2700
func (w *transactionStorageWrapper) GetAllNodes() []*storage.Node {
1✔
2701
        nodes := w.tx.GetAllNodes()
1✔
2702
        if w.namespace == "" {
2✔
2703
                return nodes
1✔
2704
        }
1✔
2705
        return w.toUserNodes(nodes)
×
2706
}
2707

2708
func (w *transactionStorageWrapper) GetInDegree(nodeID storage.NodeID) int {
1✔
2709
        return w.underlying.GetInDegree(nodeID)
1✔
2710
}
1✔
2711

2712
func (w *transactionStorageWrapper) GetOutDegree(nodeID storage.NodeID) int {
1✔
2713
        return w.underlying.GetOutDegree(nodeID)
1✔
2714
}
1✔
2715

2716
func (w *transactionStorageWrapper) GetSchema() *storage.SchemaManager {
1✔
2717
        return w.underlying.GetSchema()
1✔
2718
}
1✔
2719

2720
func (w *transactionStorageWrapper) BulkCreateNodes(nodes []*storage.Node) error {
1✔
2721
        // For bulk operations within transaction, create one by one
1✔
2722
        for _, node := range nodes {
2✔
2723
                if w.namespace == "" {
2✔
2724
                        if _, err := w.tx.CreateNode(node); err != nil {
2✔
2725
                                return err
1✔
2726
                        }
1✔
2727
                        continue
1✔
2728
                }
2729
                namespaced := storage.CopyNode(node)
1✔
2730
                namespaced.ID = w.prefixNodeID(node.ID)
1✔
2731
                if _, err := w.tx.CreateNode(namespaced); err != nil {
1✔
2732
                        return err
×
2733
                }
×
2734
        }
2735
        return nil
1✔
2736
}
2737

2738
func (w *transactionStorageWrapper) BulkCreateEdges(edges []*storage.Edge) error {
1✔
2739
        if len(edges) == 0 {
1✔
2740
                return nil
×
2741
        }
×
2742
        if w.namespace == "" {
2✔
2743
                return w.tx.BulkCreateEdges(edges)
1✔
2744
        }
1✔
2745
        namespaced := make([]*storage.Edge, len(edges))
1✔
2746
        for i, edge := range edges {
2✔
2747
                cp := storage.CopyEdge(edge)
1✔
2748
                cp.ID = w.prefixEdgeID(edge.ID)
1✔
2749
                cp.StartNode = w.prefixNodeID(edge.StartNode)
1✔
2750
                cp.EndNode = w.prefixNodeID(edge.EndNode)
1✔
2751
                namespaced[i] = cp
1✔
2752
        }
1✔
2753
        return w.tx.BulkCreateEdges(namespaced)
1✔
2754
}
2755

2756
func (w *transactionStorageWrapper) BulkDeleteNodes(ids []storage.NodeID) error {
1✔
2757
        for _, id := range ids {
2✔
2758
                if err := w.tx.DeleteNode(w.prefixNodeID(id)); err != nil {
2✔
2759
                        return err
1✔
2760
                }
1✔
2761
        }
2762
        return nil
1✔
2763
}
2764

2765
func (w *transactionStorageWrapper) BulkDeleteEdges(ids []storage.EdgeID) error {
1✔
2766
        for _, id := range ids {
2✔
2767
                if err := w.tx.DeleteEdge(w.prefixEdgeID(id)); err != nil {
2✔
2768
                        return err
1✔
2769
                }
1✔
2770
        }
2771
        return nil
1✔
2772
}
2773

2774
func (w *transactionStorageWrapper) prefixNodeID(id storage.NodeID) storage.NodeID {
1✔
2775
        if w.namespace == "" {
2✔
2776
                return id
1✔
2777
        }
1✔
2778
        prefix := w.namespace + w.separator
1✔
2779
        if strings.HasPrefix(string(id), prefix) {
2✔
2780
                return id
1✔
2781
        }
1✔
2782
        return storage.NodeID(w.namespace + w.separator + string(id))
1✔
2783
}
2784

2785
func (w *transactionStorageWrapper) unprefixNodeID(id storage.NodeID) storage.NodeID {
1✔
2786
        if w.namespace == "" {
1✔
2787
                return id
×
2788
        }
×
2789
        prefix := w.namespace + w.separator
1✔
2790
        s := string(id)
1✔
2791
        if strings.HasPrefix(s, prefix) {
2✔
2792
                return storage.NodeID(s[len(prefix):])
1✔
2793
        }
1✔
2794
        return id
1✔
2795
}
2796

2797
func (w *transactionStorageWrapper) prefixEdgeID(id storage.EdgeID) storage.EdgeID {
1✔
2798
        if w.namespace == "" {
2✔
2799
                return id
1✔
2800
        }
1✔
2801
        prefix := w.namespace + w.separator
1✔
2802
        if strings.HasPrefix(string(id), prefix) {
2✔
2803
                return id
1✔
2804
        }
1✔
2805
        return storage.EdgeID(w.namespace + w.separator + string(id))
1✔
2806
}
2807

2808
func (w *transactionStorageWrapper) unprefixEdgeID(id storage.EdgeID) storage.EdgeID {
1✔
2809
        if w.namespace == "" {
1✔
2810
                return id
×
2811
        }
×
2812
        prefix := w.namespace + w.separator
1✔
2813
        s := string(id)
1✔
2814
        if strings.HasPrefix(s, prefix) {
2✔
2815
                return storage.EdgeID(s[len(prefix):])
1✔
2816
        }
1✔
2817
        return id
1✔
2818
}
2819

2820
func (w *transactionStorageWrapper) toUserNode(node *storage.Node) *storage.Node {
1✔
2821
        if node == nil {
2✔
2822
                return nil
1✔
2823
        }
1✔
2824
        out := storage.CopyNode(node)
1✔
2825
        out.ID = w.unprefixNodeID(out.ID)
1✔
2826
        return out
1✔
2827
}
2828

2829
func (w *transactionStorageWrapper) toUserEdge(edge *storage.Edge) *storage.Edge {
1✔
2830
        if edge == nil {
1✔
2831
                return nil
×
2832
        }
×
2833
        out := storage.CopyEdge(edge)
1✔
2834
        out.ID = w.unprefixEdgeID(out.ID)
1✔
2835
        out.StartNode = w.unprefixNodeID(out.StartNode)
1✔
2836
        out.EndNode = w.unprefixNodeID(out.EndNode)
1✔
2837
        return out
1✔
2838
}
2839

2840
func (w *transactionStorageWrapper) toUserEdges(edges []*storage.Edge) []*storage.Edge {
1✔
2841
        out := make([]*storage.Edge, 0, len(edges))
1✔
2842
        for _, edge := range edges {
2✔
2843
                out = append(out, w.toUserEdge(edge))
1✔
2844
        }
1✔
2845
        return out
1✔
2846
}
2847

2848
func (w *transactionStorageWrapper) toUserNodes(nodes []*storage.Node) []*storage.Node {
1✔
2849
        out := make([]*storage.Node, 0, len(nodes))
1✔
2850
        for _, node := range nodes {
2✔
2851
                out = append(out, w.toUserNode(node))
1✔
2852
        }
1✔
2853
        return out
1✔
2854
}
2855

2856
func (w *transactionStorageWrapper) BatchGetNodes(ids []storage.NodeID) (map[storage.NodeID]*storage.Node, error) {
1✔
2857
        return w.underlying.BatchGetNodes(ids)
1✔
2858
}
1✔
2859

2860
func (w *transactionStorageWrapper) Close() error {
1✔
2861
        // Don't close underlying engine
1✔
2862
        return nil
1✔
2863
}
1✔
2864

2865
func (w *transactionStorageWrapper) NodeCount() (int64, error) {
1✔
2866
        return w.underlying.NodeCount()
1✔
2867
}
1✔
2868

2869
func (w *transactionStorageWrapper) EdgeCount() (int64, error) {
1✔
2870
        return w.underlying.EdgeCount()
1✔
2871
}
1✔
2872

2873
func (w *transactionStorageWrapper) DeleteByPrefix(prefix string) (nodesDeleted int64, edgesDeleted int64, err error) {
1✔
2874
        // DeleteByPrefix is not supported within a transaction context.
1✔
2875
        // This operation should be performed outside of a transaction.
1✔
2876
        return 0, 0, fmt.Errorf("DeleteByPrefix not supported within transaction context")
1✔
2877
}
1✔
2878

2879
// tryFastPathCompoundQuery attempts to handle common compound query patterns
2880
// using structured scanning rather than regex capture arrays.
2881
// Returns (result, true) if handled, (nil, false) if the query should go through normal routing.
2882
//
2883
// Pattern: MATCH (a:Label), (b:Label) WITH a, b LIMIT 1 CREATE (a)-[r:Type]->(b) DELETE r
2884
// This is a very common pattern in benchmarks and relationship tests.
2885
func (e *StorageExecutor) tryFastPathCompoundQuery(ctx context.Context, cypher string) (*ExecuteResult, bool) {
1✔
2886
        if match, ok := matchCompoundQueryShape(cypher); ok {
2✔
2887
                switch match.Kind {
1✔
2888
                case shapeKindCompoundCreateDeleteRel:
1✔
2889
                        e.markCompoundQueryFastPathUsed()
1✔
2890
                        return e.executeFastPathCreateDeleteRel(
1✔
2891
                                match.Captures.String("label1"),
1✔
2892
                                match.Captures.String("label2"),
1✔
2893
                                match.Captures.String("prop1"),
1✔
2894
                                match.Captures.Any("value1"),
1✔
2895
                                match.Captures.String("prop2"),
1✔
2896
                                match.Captures.Any("value2"),
1✔
2897
                                match.Captures.String("rel_type"),
1✔
2898
                        )
1✔
2899
                case shapeKindCompoundPropCreateDeleteRel:
1✔
2900
                        e.markCompoundQueryFastPathUsed()
1✔
2901
                        return e.executeFastPathCreateDeleteRel(
1✔
2902
                                match.Captures.String("label1"),
1✔
2903
                                match.Captures.String("label2"),
1✔
2904
                                match.Captures.String("prop1"),
1✔
2905
                                match.Captures.Any("value1"),
1✔
2906
                                match.Captures.String("prop2"),
1✔
2907
                                match.Captures.Any("value2"),
1✔
2908
                                match.Captures.String("rel_type"),
1✔
2909
                        )
1✔
2910
                case shapeKindCompoundPropCreateDeleteReturnCountRel:
1✔
2911
                        e.markCompoundQueryFastPathUsed()
1✔
2912
                        return e.executeFastPathCreateDeleteRelCount(
1✔
2913
                                match.Captures.String("label1"),
1✔
2914
                                match.Captures.String("label2"),
1✔
2915
                                match.Captures.String("prop1"),
1✔
2916
                                match.Captures.Any("value1"),
1✔
2917
                                match.Captures.String("prop2"),
1✔
2918
                                match.Captures.Any("value2"),
1✔
2919
                                match.Captures.String("rel_type"),
1✔
2920
                                match.Captures.String("rel_var"),
1✔
2921
                        )
1✔
2922
                }
2923
        }
2924

2925
        return nil, false
1✔
2926
}
2927

2928
// executeFastPathCreateDeleteRel executes the fast-path for MATCH...CREATE...DELETE patterns.
2929
// If prop1/prop2 are empty, uses GetFirstNodeByLabel. Otherwise uses property lookup.
2930
func (e *StorageExecutor) executeFastPathCreateDeleteRel(label1, label2, prop1 string, val1 any, prop2 string, val2 any, relType string) (*ExecuteResult, bool) {
1✔
2931
        var err error
1✔
2932

1✔
2933
        // Get node1
1✔
2934
        if prop1 == "" {
2✔
2935
                _, err = storage.FirstNodeIDByLabel(e.storage, label1)
1✔
2936
        } else {
2✔
2937
                node1 := e.findNodeByLabelAndProperty(label1, prop1, val1)
1✔
2938
                if node1 == nil {
1✔
2939
                        return nil, false
×
2940
                }
×
2941
        }
2942
        if err != nil {
2✔
2943
                return nil, false
1✔
2944
        }
1✔
2945

2946
        // Get node2
2947
        if prop2 == "" {
2✔
2948
                _, err = storage.FirstNodeIDByLabel(e.storage, label2)
1✔
2949
        } else {
2✔
2950
                node2 := e.findNodeByLabelAndProperty(label2, prop2, val2)
1✔
2951
                if node2 == nil {
2✔
2952
                        return nil, false
1✔
2953
                }
1✔
2954
        }
2955
        if err != nil {
1✔
2956
                return nil, false
×
2957
        }
×
2958

2959
        // Optimization: This pattern creates a relationship and deletes it in the same
2960
        // statement without returning it. The relationship is not observable to the user,
2961
        // and the net graph effect is a no-op, so we skip storage writes entirely.
2962
        //
2963
        // We still validate that both endpoints exist (via the lookups above) and we
2964
        // still return correct query stats for Neo4j compatibility.
2965

2966
        return &ExecuteResult{
1✔
2967
                Columns: []string{},
1✔
2968
                Rows:    [][]interface{}{},
1✔
2969
                Stats: &QueryStats{
1✔
2970
                        RelationshipsCreated: 1,
1✔
2971
                        RelationshipsDeleted: 1,
1✔
2972
                },
1✔
2973
        }, true
1✔
2974
}
2975

2976
func (e *StorageExecutor) executeFastPathCreateDeleteRelCount(label1, label2, prop1 string, val1 any, prop2 string, val2 any, relType string, relVar string) (*ExecuteResult, bool) {
1✔
2977
        var err error
1✔
2978

1✔
2979
        if prop1 == "" {
2✔
2980
                _, err = storage.FirstNodeIDByLabel(e.storage, label1)
1✔
2981
        } else {
2✔
2982
                node1 := e.findNodeByLabelAndProperty(label1, prop1, val1)
1✔
2983
                if node1 == nil {
1✔
2984
                        return nil, false
×
2985
                }
×
2986
        }
2987
        if err != nil {
2✔
2988
                return nil, false
1✔
2989
        }
1✔
2990

2991
        if prop2 == "" {
2✔
2992
                _, err = storage.FirstNodeIDByLabel(e.storage, label2)
1✔
2993
        } else {
2✔
2994
                node2 := e.findNodeByLabelAndProperty(label2, prop2, val2)
1✔
2995
                if node2 == nil {
2✔
2996
                        return nil, false
1✔
2997
                }
1✔
2998
        }
2999
        if err != nil {
1✔
3000
                return nil, false
×
3001
        }
×
3002

3003
        return &ExecuteResult{
1✔
3004
                Columns: []string{"count(" + relVar + ")"},
1✔
3005
                Rows:    [][]interface{}{{int64(1)}},
1✔
3006
                Stats: &QueryStats{
1✔
3007
                        RelationshipsCreated: 1,
1✔
3008
                        RelationshipsDeleted: 1,
1✔
3009
                },
1✔
3010
        }, true
1✔
3011
}
3012

3013
// findNodeByLabelAndProperty finds a node by label and a single property value.
3014
// Uses the node lookup cache for O(1) repeated lookups.
3015
func (e *StorageExecutor) findNodeByLabelAndProperty(label, prop string, val any) *storage.Node {
1✔
3016
        e.ensureNodeLookupCache()
1✔
3017

1✔
3018
        // Try cache first (with proper locking)
1✔
3019
        cacheKey := fmt.Sprintf("%s:{%s:%v}", label, prop, val)
1✔
3020
        cacheMu := e.nodeLookupCacheLock()
1✔
3021
        cacheMu.RLock()
1✔
3022
        if cached, ok := e.nodeLookupCache[cacheKey]; ok {
2✔
3023
                cacheMu.RUnlock()
1✔
3024
                return cached
1✔
3025
        }
1✔
3026
        cacheMu.RUnlock()
1✔
3027

1✔
3028
        // Scan nodes with label
1✔
3029
        nodes, err := e.storage.GetNodesByLabel(label)
1✔
3030
        if err != nil {
1✔
3031
                return nil
×
3032
        }
×
3033

3034
        // Find matching node
3035
        for _, node := range nodes {
2✔
3036
                if nodeVal, ok := node.Properties[prop]; ok {
2✔
3037
                        if fmt.Sprintf("%v", nodeVal) == fmt.Sprintf("%v", val) {
2✔
3038
                                // Cache for next time (with proper locking)
1✔
3039
                                cacheMu.Lock()
1✔
3040
                                e.nodeLookupCache[cacheKey] = node
1✔
3041
                                cacheMu.Unlock()
1✔
3042
                                return node
1✔
3043
                        }
1✔
3044
                }
3045
        }
3046

3047
        return nil
1✔
3048
}
3049

3050
// isSystemCommandNoGraph returns true for statements that operate on database metadata
3051
// (CREATE/DROP DATABASE, SHOW DATABASES, etc.) and must not use the async engine or
3052
// implicit transactions. These are routed to executeWithoutTransaction directly.
3053
func isSystemCommandNoGraph(cypher string) bool {
1✔
3054
        return findMultiWordKeywordIndex(cypher, "CREATE", "COMPOSITE DATABASE") == 0 ||
1✔
3055
                findMultiWordKeywordIndex(cypher, "CREATE", "DATABASE") == 0 ||
1✔
3056
                findMultiWordKeywordIndex(cypher, "CREATE", "ALIAS") == 0 ||
1✔
3057
                findMultiWordKeywordIndex(cypher, "DROP", "COMPOSITE DATABASE") == 0 ||
1✔
3058
                findMultiWordKeywordIndex(cypher, "DROP", "DATABASE") == 0 ||
1✔
3059
                findMultiWordKeywordIndex(cypher, "DROP", "ALIAS") == 0 ||
1✔
3060
                findMultiWordKeywordIndex(cypher, "SHOW", "DATABASES") == 0 ||
1✔
3061
                findMultiWordKeywordIndex(cypher, "ALTER", "DATABASE") == 0
1✔
3062
}
1✔
3063

3064
func isShowConstraintContractsCommand(cypher string) bool {
1✔
3065
        return findMultiWordKeywordIndex(cypher, "SHOW", "CONSTRAINT CONTRACTS") == 0
1✔
3066
}
1✔
3067

3068
// executeWithoutTransaction executes query without transaction wrapping (original path).
3069
func (e *StorageExecutor) executeWithoutTransaction(ctx context.Context, cypher string, upperQuery string) (*ExecuteResult, error) {
1✔
3070
        // FAST PATH: Simple MATCH-return-limit reads should never be routed through
1✔
3071
        // heavier compound planners. Keep this check first to avoid regressions when
1✔
3072
        // adding complex routing rules.
1✔
3073
        if result, handled := e.tryFastPathSimpleMatchReturnLimit(ctx, cypher, upperQuery); handled {
2✔
3074
                return result, nil
1✔
3075
        }
1✔
3076
        if result, handled := e.tryFastPathAnyMatchVectorCosine(ctx, cypher, upperQuery); handled {
2✔
3077
                return result, nil
1✔
3078
        }
1✔
3079

3080
        // FAST PATH: Check for common compound query patterns using pre-compiled regex
3081
        // This avoids multiple findKeywordIndex calls for frequently-used patterns
3082
        if result, handled := e.tryFastPathCompoundQuery(ctx, cypher); handled {
2✔
3083
                return result, nil
1✔
3084
        }
1✔
3085

3086
        // Route to appropriate handler based on query type
3087
        // upperQuery is passed in to avoid redundant conversion
3088

3089
        // Cache keyword checks to avoid repeated searches
3090
        startsWithMatch := strings.HasPrefix(upperQuery, "MATCH")
1✔
3091
        startsWithCreate := strings.HasPrefix(upperQuery, "CREATE")
1✔
3092
        startsWithMerge := strings.HasPrefix(upperQuery, "MERGE")
1✔
3093

1✔
3094
        // Correlated MATCH ... CALL { ... } must route before generic MATCH...CREATE/MERGE
1✔
3095
        // detection, because CREATE/MERGE/SET tokens can legally appear inside the
1✔
3096
        // CALL subquery body and should not trigger outer compound handlers.
1✔
3097
        if startsWithMatch && hasSubqueryPattern(cypher, callSubqueryRe) {
2✔
3098
                return e.executeMatchWithCallSubquery(ctx, cypher)
1✔
3099
        }
1✔
3100

3101
        // MATCH ... CALL procedure() must route before generic MATCH ... CREATE/MERGE
3102
        // detection because procedure names such as db.create.setNodeVectorProperty
3103
        // contain clause-looking tokens that are not outer query clauses.
3104
        if startsWithMatch {
2✔
3105
                callIdx := findKeywordIndex(cypher, "CALL")
1✔
3106
                if callIdx > 0 {
2✔
3107
                        callPart := strings.TrimSpace(cypher[callIdx:])
1✔
3108
                        if !isCallSubquery(callPart) {
2✔
3109
                                prefix := cypher[:callIdx]
1✔
3110
                                hasMutationBeforeCall := findKeywordIndexInContext(prefix, "MERGE") >= 0 ||
1✔
3111
                                        findKeywordIndexInContext(prefix, "CREATE") >= 0 ||
1✔
3112
                                        findKeywordIndexInContext(prefix, "SET") >= 0 ||
1✔
3113
                                        findKeywordIndexInContext(prefix, "DELETE") >= 0 ||
1✔
3114
                                        findKeywordIndexInContext(prefix, "DETACH DELETE") >= 0 ||
1✔
3115
                                        findKeywordIndexInContext(prefix, "REMOVE") >= 0
1✔
3116
                                if hasMutationBeforeCall {
2✔
3117
                                        goto skipMatchCallRoute
1✔
3118
                                }
3119
                                if findKeywordIndex(cypher[:callIdx], "WITH") > 0 {
2✔
3120
                                        return e.executeMatchWithClause(ctx, cypher)
1✔
3121
                                }
1✔
3122
                                return e.executeMatchWithCallProcedure(ctx, cypher)
1✔
3123
                        }
3124
                }
3125
        }
3126
skipMatchCallRoute:
3127

3128
        // MERGE queries get special handling - they have their own ON CREATE SET / ON MATCH SET logic
3129
        if startsWithMerge {
2✔
3130
                // Complex MERGE pipelines that include OPTIONAL MATCH / WITH / WHERE should
1✔
3131
                // use the segment executor instead of the single-MERGE parser.
1✔
3132
                if findKeywordIndexInContext(cypher, "OPTIONAL MATCH") > 0 ||
1✔
3133
                        findKeywordIndexInContext(cypher, "WITH") > 0 ||
1✔
3134
                        findKeywordIndexInContext(cypher, "WHERE") > 0 {
2✔
3135
                        return e.executeMultipleMerges(ctx, cypher)
1✔
3136
                }
1✔
3137
                // Check for multiple MERGEs without WITH (e.g., MERGE (a) MERGE (b) MERGE (a)-[:REL]->(b))
3138
                firstMergeEnd := findKeywordIndex(cypher[5:], ")")
1✔
3139
                if firstMergeEnd > 0 {
2✔
3140
                        afterFirstMerge := cypher[5+firstMergeEnd+1:]
1✔
3141
                        secondMergeIdx := findKeywordIndex(afterFirstMerge, "MERGE")
1✔
3142
                        if secondMergeIdx >= 0 {
2✔
3143
                                return e.executeMultipleMerges(ctx, cypher)
1✔
3144
                        }
1✔
3145
                }
3146
                return e.executeMerge(ctx, cypher)
1✔
3147
        }
3148

3149
        // Cache findKeywordIndex results for compound query detection
3150
        var mergeIdx, createIdx, withIdx, deleteIdx, optionalMatchIdx int = -1, -1, -1, -1, -1
1✔
3151

1✔
3152
        if startsWithMatch {
2✔
3153
                // Only search for keywords if query starts with MATCH
1✔
3154
                mergeIdx = findKeywordIndex(cypher, "MERGE")
1✔
3155
                createIdx = findKeywordIndex(cypher, "CREATE")
1✔
3156
                optionalMatchIdx = findMultiWordKeywordIndex(cypher, "OPTIONAL", "MATCH")
1✔
3157
        } else if startsWithCreate {
3✔
3158
                // Check for multiple CREATE statements (e.g., CREATE (a) CREATE (b) CREATE (a)-[:REL]->(b))
1✔
3159
                firstCreateEnd := findKeywordIndex(cypher[6:], ")")
1✔
3160
                if firstCreateEnd > 0 {
2✔
3161
                        afterFirstCreate := cypher[6+firstCreateEnd+1:]
1✔
3162
                        secondCreateIdx := findKeywordIndex(afterFirstCreate, "CREATE")
1✔
3163
                        if secondCreateIdx >= 0 {
2✔
3164
                                return e.executeMultipleCreates(ctx, cypher)
1✔
3165
                        }
1✔
3166
                }
3167
                // Only search for WITH/DELETE if query starts with CREATE
3168
                withIdx = findKeywordIndex(cypher, "WITH")
1✔
3169
                if withIdx > 0 {
2✔
3170
                        deleteIdx = findKeywordIndex(cypher, "DELETE")
1✔
3171
                }
1✔
3172
        }
3173

3174
        // Compound queries: MATCH ... MERGE ... (with variable references)
3175
        if startsWithMatch && mergeIdx > 0 {
2✔
3176
                return e.executeCompoundMatchMerge(ctx, cypher)
1✔
3177
        }
1✔
3178

3179
        // Compound queries: MATCH ... CREATE ... (create relationship between matched nodes)
3180
        if startsWithMatch && createIdx > 0 {
2✔
3181
                // Try the general pipeline executor first — it handles composite
1✔
3182
                // queries like MATCH ... CREATE ... WITH ... UNWIND ... MATCH ...
1✔
3183
                // CREATE ... which executeCompoundMatchCreate miscategorises
1✔
3184
                // (treats everything after the first CREATE as CREATE patterns,
1✔
3185
                // consuming intervening WITH/UNWIND/MATCH clauses as property map
1✔
3186
                // garbage).
1✔
3187
                if result, ok, err := e.executePipeline(ctx, cypher); ok {
2✔
3188
                        return result, err
1✔
3189
                }
1✔
3190
                return e.executeCompoundMatchCreate(ctx, cypher)
1✔
3191
        }
3192

3193
        // Compound queries: CREATE ... WITH ... DELETE (create then delete in same statement)
3194
        if startsWithCreate && withIdx > 0 && deleteIdx > 0 {
2✔
3195
                return e.executeCompoundCreateWithDelete(ctx, cypher)
1✔
3196
        }
1✔
3197

3198
        // UNWIND pipelines may contain trailing DELETE/SET/REMOVE keywords.
3199
        // Route UNWIND before generic mutation keyword checks so
3200
        // `UNWIND ... MATCH ... REMOVE ...` does not get misrouted to executeRemove.
3201
        if findKeywordIndex(cypher, "UNWIND") == 0 {
2✔
3202
                return e.executeUnwind(ctx, cypher)
1✔
3203
        }
1✔
3204

3205
        // Cache contains checks for DELETE - use word-boundary-aware detection
3206
        // Note: Can't use " DELETE " because DELETE is often followed by variable name (DELETE n)
3207
        // findKeywordIndex handles word boundaries properly (won't match 'ToDelete' in string literals)
3208
        hasDelete := findKeywordIndex(cypher, "DELETE") > 0 // Must be after MATCH, not at start
1✔
3209
        hasDetachDelete := containsKeywordOutsideStrings(cypher, "DETACH DELETE")
1✔
3210

1✔
3211
        // Check for compound queries - MATCH ... DELETE, MATCH ... SET, etc.
1✔
3212
        if hasDelete || hasDetachDelete {
2✔
3213
                return e.executeDelete(ctx, cypher)
1✔
3214
        }
1✔
3215

3216
        // Cache SET-related checks - use string-literal-aware detection to avoid
3217
        // matching keywords inside user content like 'MATCH (n) SET n.x = 1'
3218
        // Note: findKeywordIndex already checks word boundaries, so no need for leading space
3219
        hasSet := containsKeywordOutsideStrings(cypher, "SET")
1✔
3220
        hasOnCreateSet := containsKeywordOutsideStrings(cypher, "ON CREATE SET")
1✔
3221
        hasOnMatchSet := containsKeywordOutsideStrings(cypher, "ON MATCH SET")
1✔
3222

1✔
3223
        // NEO4J COMPAT: Handle CREATE ... SET pattern (e.g., CREATE (n) SET n.x = 1)
1✔
3224
        // Neo4j allows SET immediately after CREATE without requiring MATCH
1✔
3225
        if startsWithCreate && !isCreateProcedureCommand(cypher) && hasSet && !hasOnCreateSet && !hasOnMatchSet &&
1✔
3226
                findMultiWordKeywordIndex(cypher, "CREATE", "DECAY PROFILE") != 0 &&
1✔
3227
                findMultiWordKeywordIndex(cypher, "CREATE", "PROMOTION PROFILE") != 0 &&
1✔
3228
                findMultiWordKeywordIndex(cypher, "CREATE", "PROMOTION POLICY") != 0 {
2✔
3229
                return e.executeCreateSet(ctx, cypher)
1✔
3230
        }
1✔
3231

3232
        // Check for ALTER DATABASE before generic SET (ALTER DATABASE SET LIMIT contains "SET")
3233
        if findMultiWordKeywordIndex(cypher, "ALTER", "DATABASE") == 0 {
2✔
3234
                return e.executeAlterDatabase(ctx, cypher)
1✔
3235
        }
1✔
3236

3237
        // Only route to executeSet if it's a MATCH ... SET or standalone SET
3238
        if hasSet && !isCreateProcedureCommand(cypher) && !hasOnCreateSet && !hasOnMatchSet &&
1✔
3239
                findMultiWordKeywordIndex(cypher, "CREATE", "DECAY PROFILE") != 0 &&
1✔
3240
                findMultiWordKeywordIndex(cypher, "CREATE", "PROMOTION PROFILE") != 0 &&
1✔
3241
                findMultiWordKeywordIndex(cypher, "CREATE", "PROMOTION POLICY") != 0 &&
1✔
3242
                findMultiWordKeywordIndex(cypher, "ALTER", "DECAY PROFILE") != 0 &&
1✔
3243
                findMultiWordKeywordIndex(cypher, "ALTER", "PROMOTION PROFILE") != 0 &&
1✔
3244
                findMultiWordKeywordIndex(cypher, "ALTER", "PROMOTION POLICY") != 0 {
2✔
3245
                if startsWithMatch || findKeywordIndex(cypher, "SET") == 0 {
2✔
3246
                        return e.executeSet(ctx, cypher)
1✔
3247
                }
1✔
3248
        }
3249

3250
        // Handle MATCH ... REMOVE (property removal) - string-literal-aware
3251
        // Note: findKeywordIndex already checks word boundaries
3252
        if containsKeywordOutsideStrings(cypher, "REMOVE") {
2✔
3253
                return e.executeRemove(ctx, cypher)
1✔
3254
        }
1✔
3255

3256
        // Compound queries: MATCH ... OPTIONAL MATCH ...
3257
        // But NOT when there's a WITH clause before OPTIONAL MATCH (that's handled by executeMatchWithOptionalMatch)
3258
        if startsWithMatch && optionalMatchIdx > 0 {
2✔
3259
                // Check if there's a WITH clause BEFORE OPTIONAL MATCH
1✔
3260
                // If so, route to the specialized handler that processes WITH first
1✔
3261
                withBeforeOptional := findKeywordIndex(cypher[:optionalMatchIdx], "WITH")
1✔
3262
                if withBeforeOptional > 0 {
2✔
3263
                        // WITH comes before OPTIONAL MATCH - route to executeMatchWithOptionalMatch
1✔
3264
                        return e.executeMatchWithOptionalMatch(ctx, cypher)
1✔
3265
                }
1✔
3266
                return e.executeCompoundMatchOptionalMatch(ctx, cypher)
1✔
3267
        }
3268

3269
        switch {
1✔
3270
        case isCreateProcedureCommand(cypher):
1✔
3271
                return e.executeCreateProcedure(ctx, cypher)
1✔
3272
        case findMultiWordKeywordIndex(cypher, "CREATE", "DECAY PROFILE") == 0,
3273
                findMultiWordKeywordIndex(cypher, "CREATE", "PROMOTION PROFILE") == 0,
3274
                findMultiWordKeywordIndex(cypher, "CREATE", "PROMOTION POLICY") == 0:
1✔
3275
                return e.executeKnowledgePolicyDDL(ctx, cypher)
1✔
3276
        case findMultiWordKeywordIndex(cypher, "OPTIONAL", "MATCH") == 0:
1✔
3277
                // OPTIONAL MATCH must be at start (position 0) to be a standalone clause
1✔
3278
                // Handles flexible whitespace: "OPTIONAL MATCH", "OPTIONAL\tMATCH", "OPTIONAL\nMATCH", etc.
1✔
3279
                return e.executeOptionalMatch(ctx, cypher)
1✔
3280
        case startsWithMatch && isShortestPathQuery(cypher):
1✔
3281
                // Handle shortestPath() and allShortestPaths() queries.
1✔
3282
                // Parameter substitution must happen before parsing so the variable-
1✔
3283
                // binding lookup for `MATCH (start:Star {starId: $startId})` resolves
1✔
3284
                // to the actual property value rather than the literal `$startId`.
1✔
3285
                // Without this, `findNodeByPattern` matches no node, the executor
1✔
3286
                // falls through to AllNodes() × AllNodes(), and the BFS explodes.
1✔
3287
                spCypher := cypher
1✔
3288
                if params := getParamsFromContext(ctx); params != nil {
1✔
3289
                        spCypher = e.substituteParams(spCypher, params)
×
3290
                }
×
3291
                query, err := e.parseShortestPathQuery(ctx, spCypher)
1✔
3292
                if err != nil {
1✔
3293
                        return nil, err
×
3294
                }
×
3295
                return e.executeShortestPathQuery(ctx, query)
1✔
3296
        case startsWithMatch:
1✔
3297
                // Multi-MATCH chains have dedicated routing inside executeMatch (executeMultiMatch,
1✔
3298
                // executeChainedMatchWithAggregations). Keep them on that path to preserve WHERE
1✔
3299
                // semantics across MATCH boundaries.
1✔
3300
                matchCount := countKeywordOccurrences(upperQuery, "MATCH")
1✔
3301
                optionalMatchCount := countKeywordOccurrences(upperQuery, "OPTIONAL MATCH")
1✔
3302
                isMultiMatch := matchCount-optionalMatchCount > 1
1✔
3303
                if !isMultiMatch {
2✔
3304
                        // Check for optimizable patterns FIRST
1✔
3305
                        patternInfo := DetectQueryPattern(ctx, cypher)
1✔
3306
                        if patternInfo.IsOptimizable() {
2✔
3307
                                if result, ok := e.ExecuteOptimized(ctx, cypher, patternInfo); ok {
2✔
3308
                                        return result, nil
1✔
3309
                                }
1✔
3310
                                // Fall through to generic on optimization failure
3311
                        }
3312
                }
3313
                return e.executeMatch(ctx, cypher)
1✔
3314
        case findMultiWordKeywordIndex(cypher, "CREATE", "CONSTRAINT") == 0,
3315
                findMultiWordKeywordIndex(cypher, "CREATE", "RANGE INDEX") == 0,
3316
                findMultiWordKeywordIndex(cypher, "CREATE", "FULLTEXT INDEX") == 0,
3317
                findMultiWordKeywordIndex(cypher, "CREATE", "VECTOR INDEX") == 0,
3318
                findKeywordIndex(cypher, "CREATE INDEX") == 0:
1✔
3319
                // Schema commands - constraints and indexes (check more specific patterns first)
1✔
3320
                // Must be at start (position 0) to be a standalone clause
1✔
3321
                return e.executeSchemaCommand(ctx, cypher)
1✔
3322
        case findMultiWordKeywordIndex(cypher, "CREATE", "COMPOSITE DATABASE") == 0:
×
3323
                // System command: CREATE COMPOSITE DATABASE (check before CREATE DATABASE)
×
3324
                // Must be at start (position 0) to be a standalone clause
×
3325
                return e.executeCreateCompositeDatabase(ctx, cypher)
×
3326
        case findMultiWordKeywordIndex(cypher, "CREATE", "DATABASE") == 0:
1✔
3327
                // System command: CREATE DATABASE (check before generic CREATE)
1✔
3328
                // Must be at start (position 0) to be a standalone clause
1✔
3329
                // Handles flexible whitespace: "CREATE DATABASE", "CREATE\tDATABASE", "CREATE\nDATABASE", etc.
1✔
3330
                return e.executeCreateDatabase(ctx, cypher)
1✔
3331
        case findMultiWordKeywordIndex(cypher, "CREATE", "ALIAS") == 0:
1✔
3332
                // System command: CREATE ALIAS (check before generic CREATE)
1✔
3333
                // Must be at start (position 0) to be a standalone clause
1✔
3334
                // Handles flexible whitespace: "CREATE ALIAS", "CREATE\tALIAS", "CREATE\nALIAS", etc.
1✔
3335
                return e.executeCreateAlias(ctx, cypher)
1✔
3336
        case startsWithCreate:
1✔
3337
                return e.executeCreate(ctx, cypher)
1✔
3338
        case hasDelete || hasDetachDelete:
×
3339
                // DELETE/DETACH DELETE already detected above with findKeywordIndex
×
3340
                return e.executeDelete(ctx, cypher)
×
3341
        case findKeywordIndex(cypher, "CALL") == 0:
1✔
3342
                // Distinguish CALL {} subquery from CALL procedure()
1✔
3343
                // Must be at start (position 0) to be a standalone clause
1✔
3344
                if isCallSubquery(cypher) {
2✔
3345
                        return e.executeCallSubquery(ctx, cypher)
1✔
3346
                }
1✔
3347
                return e.executeCall(ctx, cypher)
1✔
3348
        case findKeywordIndex(cypher, "RETURN") == 0:
1✔
3349
                // Must be at start (position 0) to be a standalone clause
1✔
3350
                return e.executeReturn(ctx, cypher)
1✔
3351
        case findMultiWordKeywordIndex(cypher, "DROP", "COMPOSITE DATABASE") == 0:
×
3352
                // System command: DROP COMPOSITE DATABASE (check before DROP DATABASE)
×
3353
                // Must be at start (position 0) to be a standalone clause
×
3354
                return e.executeDropCompositeDatabase(ctx, cypher)
×
3355
        case findMultiWordKeywordIndex(cypher, "DROP", "DATABASE") == 0:
1✔
3356
                // System command: DROP DATABASE (check before generic DROP)
1✔
3357
                // Must be at start (position 0) to be a standalone clause
1✔
3358
                // Handles flexible whitespace: "DROP DATABASE", "DROP\tDATABASE", "DROP\nDATABASE", etc.
1✔
3359
                return e.executeDropDatabase(ctx, cypher)
1✔
3360
        case findMultiWordKeywordIndex(cypher, "DROP", "ALIAS") == 0:
1✔
3361
                // System command: DROP ALIAS (check before generic DROP)
1✔
3362
                // Must be at start (position 0) to be a standalone clause
1✔
3363
                // Handles flexible whitespace: "DROP ALIAS", "DROP\tALIAS", "DROP\nALIAS", etc.
1✔
3364
                return e.executeDropAlias(ctx, cypher)
1✔
3365
        case findMultiWordKeywordIndex(cypher, "DROP", "CONSTRAINT") == 0:
1✔
3366
                // Schema command: DROP CONSTRAINT (must not be treated as generic DROP no-op).
1✔
3367
                // Must be at start (position 0) to be a standalone clause.
1✔
3368
                return e.executeSchemaCommand(ctx, cypher)
1✔
3369
        case findMultiWordKeywordIndex(cypher, "DROP", "DECAY PROFILE") == 0,
3370
                findMultiWordKeywordIndex(cypher, "DROP", "PROMOTION PROFILE") == 0,
3371
                findMultiWordKeywordIndex(cypher, "DROP", "PROMOTION POLICY") == 0:
×
3372
                return e.executeKnowledgePolicyDDL(ctx, cypher)
×
3373
        case isDropProcedureCommand(cypher):
1✔
3374
                return e.executeDropProcedure(ctx, cypher)
1✔
3375
        case findMultiWordKeywordIndex(cypher, "DROP", "INDEX") == 0:
1✔
3376
                // DROP INDEX — execute real drop against the target schema.
1✔
3377
                return e.executeDropIndex(ctx, cypher)
1✔
3378
        case findKeywordIndex(cypher, "DROP") == 0:
×
3379
                // Other DROP variants (not INDEX, not CONSTRAINT, not ALIAS, not PROCEDURE)
×
3380
                // treat as no-op for forward compatibility.
×
3381
                return &ExecuteResult{Columns: []string{}, Rows: [][]interface{}{}}, nil
×
3382
        case findKeywordIndex(cypher, "WITH") == 0:
1✔
3383
                // Must be at start (position 0) to be a standalone clause
1✔
3384
                return e.executeWith(ctx, cypher)
1✔
3385
        case findKeywordIndex(cypher, "UNWIND") == 0:
×
3386
                // Must be at start (position 0) to be a standalone clause
×
3387
                return e.executeUnwind(ctx, cypher)
×
3388
        case findKeywordIndex(cypher, "UNION ALL") >= 0:
×
3389
                // UNION ALL can appear anywhere in query
×
3390
                return e.executeUnion(ctx, cypher, true)
×
3391
        case findKeywordIndex(cypher, "UNION") >= 0:
×
3392
                // UNION can appear anywhere in query
×
3393
                return e.executeUnion(ctx, cypher, false)
×
3394
        case findKeywordIndex(cypher, "FOREACH") == 0:
1✔
3395
                // Must be at start (position 0) to be a standalone clause
1✔
3396
                return e.executeForeach(ctx, cypher)
1✔
3397
        case findKeywordIndex(cypher, "LOAD CSV") == 0:
1✔
3398
                // Must be at start (position 0) to be a standalone clause
1✔
3399
                return e.executeLoadCSV(ctx, cypher)
1✔
3400
        // SHOW commands for Neo4j compatibility
3401
        case findMultiWordKeywordIndex(cypher, "SHOW", "FULLTEXT INDEXES") == 0,
3402
                findMultiWordKeywordIndex(cypher, "SHOW", "FULLTEXT INDEX") == 0,
3403
                findMultiWordKeywordIndex(cypher, "SHOW", "RANGE INDEXES") == 0,
3404
                findMultiWordKeywordIndex(cypher, "SHOW", "RANGE INDEX") == 0,
3405
                findMultiWordKeywordIndex(cypher, "SHOW", "VECTOR INDEXES") == 0,
3406
                findMultiWordKeywordIndex(cypher, "SHOW", "VECTOR INDEX") == 0:
1✔
3407
                // Must be at start (position 0) to be a standalone clause
1✔
3408
                return e.executeShowIndexes(ctx, cypher)
1✔
3409
        case findMultiWordKeywordIndex(cypher, "SHOW", "INDEXES") == 0,
3410
                findMultiWordKeywordIndex(cypher, "SHOW", "INDEX") == 0:
1✔
3411
                // Must be at start (position 0) to be a standalone clause
1✔
3412
                return e.executeShowIndexes(ctx, cypher)
1✔
3413
        case findMultiWordKeywordIndex(cypher, "SHOW", "DECAY PROFILES") == 0,
3414
                findMultiWordKeywordIndex(cypher, "SHOW", "PROMOTION PROFILES") == 0,
3415
                findMultiWordKeywordIndex(cypher, "SHOW", "PROMOTION POLICIES") == 0:
1✔
3416
                return e.executeKnowledgePolicyDDL(ctx, cypher)
1✔
3417
        case findMultiWordKeywordIndex(cypher, "SHOW", "CONSTRAINTS") == 0,
3418
                findMultiWordKeywordIndex(cypher, "SHOW", "CONSTRAINT") == 0:
1✔
3419
                // Must be at start (position 0) to be a standalone clause
1✔
3420
                return e.executeShowConstraints(ctx, cypher)
1✔
3421
        case findMultiWordKeywordIndex(cypher, "SHOW", "PROCEDURES") == 0:
1✔
3422
                // Must be at start (position 0) to be a standalone clause
1✔
3423
                return e.executeShowProcedures(ctx, cypher)
1✔
3424
        case findKeywordIndex(cypher, "SHOW FUNCTIONS") == 0:
1✔
3425
                // Must be at start (position 0) to be a standalone clause
1✔
3426
                return e.executeShowFunctions(ctx, cypher)
1✔
3427
        case findMultiWordKeywordIndex(cypher, "SHOW", "COMPOSITE DATABASES") == 0:
1✔
3428
                // System command: SHOW COMPOSITE DATABASES (check before SHOW DATABASES)
1✔
3429
                // Must be at start (position 0) to be a standalone clause
1✔
3430
                return e.executeShowCompositeDatabases(ctx, cypher)
1✔
3431
        case findMultiWordKeywordIndex(cypher, "SHOW", "CONSTITUENTS") == 0:
×
3432
                // System command: SHOW CONSTITUENTS
×
3433
                // Must be at start (position 0) to be a standalone clause
×
3434
                return e.executeShowConstituents(ctx, cypher)
×
3435
        case findMultiWordKeywordIndex(cypher, "SHOW", "DATABASES") == 0:
1✔
3436
                // System command: SHOW DATABASES (plural - check before singular)
1✔
3437
                // Must be at start (position 0) to be a standalone clause
1✔
3438
                // Handles flexible whitespace: "SHOW DATABASES", "SHOW\tDATABASES", "SHOW\nDATABASES", etc.
1✔
3439
                return e.executeShowDatabases(ctx, cypher)
1✔
3440
        case findMultiWordKeywordIndex(cypher, "SHOW", "DATABASE") == 0:
1✔
3441
                // System command: SHOW DATABASE (singular)
1✔
3442
                // Must be at start (position 0) to be a standalone clause
1✔
3443
                // Handles flexible whitespace: "SHOW DATABASE", "SHOW\tDATABASE", "SHOW\nDATABASE", etc.
1✔
3444
                return e.executeShowDatabase(ctx, cypher)
1✔
3445
        case findMultiWordKeywordIndex(cypher, "SHOW", "ALIASES") == 0:
1✔
3446
                // System command: SHOW ALIASES
1✔
3447
                // Must be at start (position 0) to be a standalone clause
1✔
3448
                // Handles flexible whitespace: "SHOW ALIASES", "SHOW\tALIASES", "SHOW\nALIASES", etc.
1✔
3449
                return e.executeShowAliases(ctx, cypher)
1✔
3450
        case findMultiWordKeywordIndex(cypher, "ALTER", "COMPOSITE DATABASE") == 0:
1✔
3451
                // System command: ALTER COMPOSITE DATABASE (check before ALTER DATABASE)
1✔
3452
                // Must be at start (position 0) to be a standalone clause
1✔
3453
                return e.executeAlterCompositeDatabase(ctx, cypher)
1✔
3454
        case findMultiWordKeywordIndex(cypher, "ALTER", "DECAY PROFILE") == 0,
3455
                findMultiWordKeywordIndex(cypher, "ALTER", "PROMOTION PROFILE") == 0,
3456
                findMultiWordKeywordIndex(cypher, "ALTER", "PROMOTION POLICY") == 0:
×
3457
                return e.executeKnowledgePolicyDDL(ctx, cypher)
×
3458
        // Note: ALTER DATABASE is handled earlier (before SET check) to avoid routing conflict
3459
        case findMultiWordKeywordIndex(cypher, "SHOW", "LIMITS") == 0:
1✔
3460
                // System command: SHOW LIMITS
1✔
3461
                // Must be at start (position 0) to be a standalone clause
1✔
3462
                return e.executeShowLimits(ctx, cypher)
1✔
3463
        default:
1✔
3464
                firstWord := strings.Split(upperQuery, " ")[0]
1✔
3465
                return nil, fmt.Errorf("unsupported query type: %s (supported: MATCH, CREATE, MERGE, DELETE, SET, REMOVE, RETURN, WITH, UNWIND, CALL, FOREACH, LOAD CSV, SHOW, DROP, ALTER)", firstWord)
1✔
3466
        }
3467
}
3468

3469
// executeReturn handles simple RETURN statements (e.g., "RETURN 1").
3470
func (e *StorageExecutor) executeReturn(ctx context.Context, cypher string) (*ExecuteResult, error) {
1✔
3471
        // Substitute parameters before processing
1✔
3472
        if params := getParamsFromContext(ctx); params != nil {
2✔
3473
                cypher = e.substituteParams(cypher, params)
1✔
3474
        }
1✔
3475

3476
        // Parse RETURN clause - use word boundary detection
3477
        returnIdx := findKeywordIndex(cypher, "RETURN")
1✔
3478
        if returnIdx == -1 {
2✔
3479
                return nil, fmt.Errorf("RETURN clause not found in query: %q", truncateQuery(cypher, 80))
1✔
3480
        }
1✔
3481

3482
        returnClause := strings.TrimSpace(cypher[returnIdx+6:])
1✔
3483
        // Strip trailing modifiers from RETURN projection (single-row RETURN path).
1✔
3484
        // This prevents "ORDER BY ..." from being treated as an additional projection item.
1✔
3485
        if cut := firstTopLevelModifierIndex(returnClause); cut >= 0 {
2✔
3486
                returnClause = strings.TrimSpace(returnClause[:cut])
1✔
3487
        }
1✔
3488

3489
        // Handle simple literal returns like "RETURN 1" or "RETURN true"
3490
        parts := splitReturnExpressions(returnClause)
1✔
3491
        columns := make([]string, 0, len(parts))
1✔
3492
        values := make([]interface{}, 0, len(parts))
1✔
3493

1✔
3494
        for _, part := range parts {
2✔
3495
                part = strings.TrimSpace(part)
1✔
3496

1✔
3497
                // Check for alias (AS)
1✔
3498
                alias := part
1✔
3499
                upperPart := strings.ToUpper(part)
1✔
3500
                if asIdx := strings.Index(upperPart, " AS "); asIdx != -1 {
2✔
3501
                        alias = strings.TrimSpace(part[asIdx+4:])
1✔
3502
                        part = strings.TrimSpace(part[:asIdx])
1✔
3503
                }
1✔
3504

3505
                columns = append(columns, alias)
1✔
3506

1✔
3507
                // Handle NULL literal explicitly first
1✔
3508
                if strings.EqualFold(part, "null") {
2✔
3509
                        values = append(values, nil)
1✔
3510
                        continue
1✔
3511
                }
3512

3513
                // Fabric APPLY correlated bindings (e.g., RETURN textKey, textKey128, texts).
3514
                if isValidIdentifier(part) {
2✔
3515
                        if v, ok := e.fabricRecordBindings[part]; ok {
2✔
3516
                                values = append(values, v)
1✔
3517
                                continue
1✔
3518
                        }
3519
                }
3520

3521
                // Try to evaluate as a function or expression first
3522
                result := e.evaluateExpressionWithContext(ctx, part, nil, nil)
1✔
3523
                if result != nil {
2✔
3524
                        values = append(values, result)
1✔
3525
                        continue
1✔
3526
                }
3527

3528
                // Parse literal value
3529
                if part == "1" || strings.HasPrefix(strings.ToLower(part), "true") {
1✔
3530
                        values = append(values, int64(1))
×
3531
                } else if part == "0" || strings.HasPrefix(strings.ToLower(part), "false") {
1✔
3532
                        values = append(values, int64(0))
×
3533
                } else if strings.HasPrefix(part, "'") && strings.HasSuffix(part, "'") {
1✔
3534
                        values = append(values, part[1:len(part)-1])
×
3535
                } else if strings.HasPrefix(part, "\"") && strings.HasSuffix(part, "\"") {
1✔
3536
                        values = append(values, part[1:len(part)-1])
×
3537
                } else {
1✔
3538
                        // Try to parse as number
1✔
3539
                        if val, err := strconv.ParseInt(part, 10, 64); err == nil {
1✔
3540
                                values = append(values, val)
×
3541
                        } else if val, err := strconv.ParseFloat(part, 64); err == nil {
1✔
3542
                                values = append(values, val)
×
3543
                        } else {
1✔
3544
                                // Return as string
1✔
3545
                                values = append(values, part)
1✔
3546
                        }
1✔
3547
                }
3548
        }
3549

3550
        return &ExecuteResult{
1✔
3551
                Columns: columns,
1✔
3552
                Rows:    [][]interface{}{values},
1✔
3553
        }, nil
1✔
3554
}
3555

3556
func firstTopLevelModifierIndex(clause string) int {
1✔
3557
        cut := -1
1✔
3558
        for _, kw := range []string{"ORDER BY", "SKIP", "LIMIT"} {
2✔
3559
                if idx := topLevelKeywordIndex(clause, kw); idx >= 0 && (cut == -1 || idx < cut) {
2✔
3560
                        cut = idx
1✔
3561
                }
1✔
3562
        }
3563
        return cut
1✔
3564
}
3565

3566
// splitReturnExpressions splits RETURN expressions by comma, respecting parentheses and brackets depth
3567
func splitReturnExpressions(clause string) []string {
1✔
3568
        var parts []string
1✔
3569
        var current strings.Builder
1✔
3570
        parenDepth := 0
1✔
3571
        bracketDepth := 0
1✔
3572
        inQuote := false
1✔
3573
        quoteChar := rune(0)
1✔
3574

1✔
3575
        for _, ch := range clause {
2✔
3576
                switch {
1✔
3577
                case (ch == '\'' || ch == '"') && !inQuote:
1✔
3578
                        inQuote = true
1✔
3579
                        quoteChar = ch
1✔
3580
                        current.WriteRune(ch)
1✔
3581
                case ch == quoteChar && inQuote:
1✔
3582
                        inQuote = false
1✔
3583
                        quoteChar = 0
1✔
3584
                        current.WriteRune(ch)
1✔
3585
                case ch == '(' && !inQuote:
1✔
3586
                        parenDepth++
1✔
3587
                        current.WriteRune(ch)
1✔
3588
                case ch == ')' && !inQuote:
1✔
3589
                        parenDepth--
1✔
3590
                        current.WriteRune(ch)
1✔
3591
                case ch == '[' && !inQuote:
1✔
3592
                        bracketDepth++
1✔
3593
                        current.WriteRune(ch)
1✔
3594
                case ch == ']' && !inQuote:
1✔
3595
                        bracketDepth--
1✔
3596
                        current.WriteRune(ch)
1✔
3597
                case ch == ',' && parenDepth == 0 && bracketDepth == 0 && !inQuote:
1✔
3598
                        parts = append(parts, current.String())
1✔
3599
                        current.Reset()
1✔
3600
                default:
1✔
3601
                        current.WriteRune(ch)
1✔
3602
                }
3603
        }
3604

3605
        if current.Len() > 0 {
2✔
3606
                parts = append(parts, current.String())
1✔
3607
        }
1✔
3608

3609
        return parts
1✔
3610
}
3611

3612
// validateSyntax performs syntax validation.
3613
// When NORNICDB_PARSER=antlr, uses ANTLR for strict OpenCypher grammar validation.
3614
// When NORNICDB_PARSER=nornic (default), uses fast inline validation.
3615
func (e *StorageExecutor) validateSyntax(cypher string) error {
1✔
3616
        // Use ANTLR parser for validation when configured
1✔
3617
        if config.IsANTLRParser() {
2✔
3618
                return e.validateSyntaxANTLR(cypher)
1✔
3619
        }
1✔
3620
        return e.validateSyntaxNornic(cypher)
1✔
3621
}
3622

3623
// validateSyntaxANTLR uses ANTLR for strict OpenCypher grammar validation.
3624
// Provides detailed error messages with line/column information.
3625
func (e *StorageExecutor) validateSyntaxANTLR(cypher string) error {
1✔
3626
        return antlr.Validate(cypher)
1✔
3627
}
1✔
3628

3629
// validateSyntaxNornic performs fast inline syntax validation.
3630
func (e *StorageExecutor) validateSyntaxNornic(cypher string) error {
1✔
3631
        if e.hasCachedValidSyntax(cypher) {
2✔
3632
                return nil
1✔
3633
        }
1✔
3634
        if !hasValidStartKeyword(cypher) {
2✔
3635
                return fmt.Errorf("syntax error: query must start with a valid clause (MATCH, CREATE, MERGE, DELETE, CALL, SHOW, EXPLAIN, PROFILE, ALTER, USE, BEGIN, COMMIT, ROLLBACK, etc.)")
1✔
3636
        }
1✔
3637

3638
        // Check balanced parentheses
3639
        parenCount := 0
1✔
3640
        bracketCount := 0
1✔
3641
        braceCount := 0
1✔
3642
        inString := false
1✔
3643
        stringChar := byte(0)
1✔
3644

1✔
3645
        for i := 0; i < len(cypher); i++ {
2✔
3646
                c := cypher[i]
1✔
3647

1✔
3648
                if inString {
2✔
3649
                        if c == stringChar && (i == 0 || cypher[i-1] != '\\') {
2✔
3650
                                inString = false
1✔
3651
                        }
1✔
3652
                        continue
1✔
3653
                }
3654

3655
                switch c {
1✔
3656
                case '"', '\'':
1✔
3657
                        inString = true
1✔
3658
                        stringChar = c
1✔
3659
                case '(':
1✔
3660
                        parenCount++
1✔
3661
                case ')':
1✔
3662
                        parenCount--
1✔
3663
                case '[':
1✔
3664
                        bracketCount++
1✔
3665
                case ']':
1✔
3666
                        bracketCount--
1✔
3667
                case '{':
1✔
3668
                        braceCount++
1✔
3669
                case '}':
1✔
3670
                        braceCount--
1✔
3671
                }
3672

3673
                if parenCount < 0 || bracketCount < 0 || braceCount < 0 {
2✔
3674
                        return fmt.Errorf("syntax error: unbalanced brackets at position %d", i)
1✔
3675
                }
1✔
3676
        }
3677

3678
        if parenCount != 0 {
2✔
3679
                return fmt.Errorf("syntax error: unbalanced parentheses")
1✔
3680
        }
1✔
3681
        if bracketCount != 0 {
2✔
3682
                return fmt.Errorf("syntax error: unbalanced square brackets")
1✔
3683
        }
1✔
3684
        if braceCount != 0 {
2✔
3685
                return fmt.Errorf("syntax error: unbalanced curly braces")
1✔
3686
        }
1✔
3687
        if inString {
2✔
3688
                return fmt.Errorf("syntax error: unclosed quote")
1✔
3689
        }
1✔
3690

3691
        e.markCachedValidSyntax(cypher)
1✔
3692
        return nil
1✔
3693
}
3694

3695
var validSyntaxStarts = [...]string{
3696
        "MATCH", "CREATE", "MERGE", "DELETE", "DETACH", "CALL", "RETURN", "WITH",
3697
        "UNWIND", "OPTIONAL", "DROP", "SHOW", "FOREACH", "LOAD", "EXPLAIN",
3698
        "PROFILE", "ALTER", "USE", "BEGIN", "COMMIT", "ROLLBACK",
3699
}
3700

3701
func hasValidStartKeyword(cypher string) bool {
1✔
3702
        for _, start := range validSyntaxStarts {
2✔
3703
                if startsWithKeywordFold(cypher, start) {
2✔
3704
                        return true
1✔
3705
                }
1✔
3706
        }
3707
        return false
1✔
3708
}
3709

3710
// ensureSyntaxValidationCache lazily installs the syntax-validation cache
3711
// pointer using sync.Once so concurrent CALL { ... } subqueries (which fan
3712
// out via executeCallTailParallel) cannot race on the pointer write. The
3713
// underlying cache itself is already mutex-guarded; the race was on the
3714
// initial pointer assignment.
3715
func (e *StorageExecutor) ensureSyntaxValidationCache() *syntaxValidationCache {
1✔
3716
        e.syntaxValidationOnce.Do(func() {
2✔
3717
                if e.syntaxValidationCache == nil {
2✔
3718
                        e.syntaxValidationCache = &syntaxValidationCache{
1✔
3719
                                cache: make(map[string]struct{}, 1024),
1✔
3720
                                max:   4096,
1✔
3721
                        }
1✔
3722
                }
1✔
3723
        })
3724
        return e.syntaxValidationCache
1✔
3725
}
3726

3727
func (e *StorageExecutor) hasCachedValidSyntax(cypher string) bool {
1✔
3728
        if cypher == "" {
1✔
3729
                return false
×
3730
        }
×
3731
        c := e.ensureSyntaxValidationCache()
1✔
3732
        c.mu.RLock()
1✔
3733
        _, ok := c.cache[cypher]
1✔
3734
        c.mu.RUnlock()
1✔
3735
        return ok
1✔
3736
}
3737

3738
func (e *StorageExecutor) markCachedValidSyntax(cypher string) {
1✔
3739
        if cypher == "" {
1✔
3740
                return
×
3741
        }
×
3742
        c := e.ensureSyntaxValidationCache()
1✔
3743
        c.mu.Lock()
1✔
3744
        if len(c.cache) >= c.max {
2✔
3745
                for k := range c.cache {
2✔
3746
                        delete(c.cache, k)
1✔
3747
                        break
1✔
3748
                }
3749
        }
3750
        c.cache[cypher] = struct{}{}
1✔
3751
        c.mu.Unlock()
1✔
3752
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc