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

orneryd / NornicDB / 28606905607

02 Jul 2026 04:49PM UTC coverage: 89.116% (+0.04%) from 89.08%
28606905607

push

github

orneryd
adding comment explaining node-delete cascade effects when pre-delete adjacency returning an error

2 of 2 new or added lines in 1 file covered. (100.0%)

734 existing lines in 21 files now uncovered.

145761 of 163564 relevant lines covered (89.12%)

1.04 hits per line

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

90.47
/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
        "strings"
119
        "sync"
120
        "time"
121
        "unicode"
122

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

327
        // inferenceManager optionally provides LLM inference for db.infer.
328
        inferenceManager InferenceManager
329

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

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

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

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

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

358
        decayMismatchLogged bool
359
        hotPathTraceState   *hotPathTraceState
360

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

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

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

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

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

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

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

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

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

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

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

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

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

518
type hotPathTraceState struct {
519
        mu    sync.RWMutex
520
        trace HotPathTrace
521
}
522

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1229
                return result, nil
1✔
1230
        }
1231

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1454
        return result, err
1✔
1455
}
1456

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

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

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

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

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

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

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

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

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

1547
                        builder.WriteRune(r)
1✔
1548

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

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

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

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

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

1608
        if !changed {
2✔
1609
                return query
1✔
1610
        }
1✔
1611

1612
        return builder.String()
1✔
1613
}
1614

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

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

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

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

1665
func cypherSyntaxConfusableReplacement(r rune) (string, bool) {
1✔
1666
        switch r {
1✔
1667
        case 'โ†’':
1✔
1668
                return "->", true
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
        default:
1✔
1702
                return "", false
1✔
1703
        }
1704
}
1705

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

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

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

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

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

1✔
1746
        for engine != nil && !visited[engine] {
2✔
1747
                visited[engine] = true
1✔
1748

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

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

1777
        return out
1✔
1778
}
1779

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

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

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

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

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

1854
        if len(nodePatterns) == 0 {
1✔
UNCOV
1855
                return nil, nil, false
×
UNCOV
1856
        }
×
1857

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

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

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

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

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

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

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

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

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

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

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

1940
        return result, nil, true
1✔
1941
}
1942

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2196
        return result, nil
1✔
2197
}
2198

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

2202
var ctxKeyTxStorage = ctxKeyTxStorageType{}
2203

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

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

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

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

2290
var ctxKeyUseDatabase = ctxKeyUseDatabaseType{}
2291

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

2295
var ctxKeyAuthToken = ctxKeyAuthTokenType{}
2296

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

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

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

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

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

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

UNCOV
2357
        return nil, dbName
×
2358
}
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