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

orneryd / NornicDB / 27146426495

08 Jun 2026 02:56PM UTC coverage: 89.558% (-0.04%) from 89.601%
27146426495

push

github

orneryd
fix(cypher): repair SET/MERGE semantics and MATCH/WITH CALL routing

Add regression coverage for four Cypher bugs, fix whole-map SET replacement,
UNWIND-bound merge/set alias handling, dynamic label application, and route
MATCH ... WITH ... CALL queries through the correct execution path so the
vector property procedure actually runs.

71 of 156 new or added lines in 7 files covered. (45.51%)

29 existing lines in 11 files now uncovered.

139161 of 155387 relevant lines covered (89.56%)

1.05 hits per line

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

98.42
/pkg/cypher/pattern_parser.go
1
// Pattern parsing for NornicDB Cypher.
2
//
3
// This file contains functions for parsing Cypher node and relationship patterns.
4
// Patterns are the core syntax for describing graph structures in queries.
5
//
6
// # Pattern Syntax
7
//
8
// Node patterns:
9
//
10
//        (n)                    - Anonymous node
11
//        (n:Label)              - Node with label
12
//        (n:Label {prop: val})  - Node with label and properties
13
//        (:Label)               - Anonymous node with label
14
//
15
// Property patterns:
16
//
17
//        {name: 'Alice'}        - String property
18
//        {age: 30}              - Integer property
19
//        {active: true}         - Boolean property
20
//        {tags: ['a', 'b']}     - Array property
21
//
22
// # Parsing Process
23
//
24
//  1. parseNodePattern - Extract variable, labels, and properties
25
//  2. parseProperties - Parse {key: value, ...} syntax
26
//  3. parsePropertyValue - Convert string values to Go types
27
//  4. parseArrayValue - Handle array literals [1, 2, 3]
28
//
29
// # ELI12
30
//
31
// Pattern parsing is like reading a recipe:
32
//
33
//        "(alice:Person {name: 'Alice', age: 30})"
34
//
35
// The parser breaks this down:
36
//   - Variable: "alice" (what we'll call this in our query)
37
//   - Label: "Person" (what type of thing it is)
38
//   - Properties: name='Alice', age=30 (details about it)
39
//
40
// It's like reading "the red ball" and understanding:
41
//   - "ball" is what it is
42
//   - "red" describes it
43
//
44
// # Neo4j Compatibility
45
//
46
// Pattern parsing matches Neo4j Cypher syntax exactly for compatibility.
47

48
package cypher
49

50
import (
51
        "context"
52
        "strconv"
53
        "strings"
54
)
55

56
// containsReservedKeyword checks if a string contains Cypher reserved keywords
57
// or special characters that could indicate an injection attempt.
58
// Only matches WHOLE keywords (not substrings like "OR" in "Order").
59
func containsReservedKeyword(s string) bool {
1✔
60
        // Check for special characters that shouldn't be in identifiers
1✔
61
        dangerousChars := []string{"--", "//", "/*", "*/", ";", "`", "\"", "'", "(", ")", "[", "]", "{", "}", ","}
1✔
62
        for _, ch := range dangerousChars {
2✔
63
                if strings.Contains(s, ch) {
2✔
64
                        return true
1✔
65
                }
1✔
66
        }
67
        // Only check for space (indicates multiple tokens which is invalid for identifiers)
68
        if strings.Contains(s, " ") {
2✔
69
                return true
1✔
70
        }
1✔
71
        return false
1✔
72
}
73

74
// parseNodePattern parses a Cypher node pattern like (n:Label {prop: value}).
75
//
76
// # Parameters
77
//
78
//   - pattern: The node pattern string (with or without parentheses)
79
//
80
// # Returns
81
//
82
//   - nodePatternInfo containing variable, labels, and properties
83
//
84
// # Example
85
//
86
//        parseNodePattern(ctx, "(n:Person {name: 'Alice'})")
87
//        // Returns: {variable: "n", labels: ["Person"], properties: {"name": "Alice"}}
88
//
89
//        parseNodePattern(ctx, "(:Employee)")
90
//        // Returns: {variable: "", labels: ["Employee"], properties: {}}
91
func (e *StorageExecutor) parseNodePattern(ctx context.Context, pattern string) nodePatternInfo {
1✔
92
        info := nodePatternInfo{
1✔
93
                labels:     []string{},
1✔
94
                properties: make(map[string]interface{}),
1✔
95
        }
1✔
96

1✔
97
        // Remove outer parens
1✔
98
        pattern = strings.TrimSpace(pattern)
1✔
99
        if strings.HasPrefix(pattern, "(") && strings.HasSuffix(pattern, ")") {
2✔
100
                pattern = pattern[1 : len(pattern)-1]
1✔
101
        }
1✔
102

103
        // Extract properties
104
        braceIdx := strings.Index(pattern, "{")
1✔
105
        if braceIdx >= 0 {
2✔
106
                propsStr := pattern[braceIdx:]
1✔
107
                pattern = pattern[:braceIdx]
1✔
108
                info.properties = e.parseProperties(ctx, propsStr)
1✔
109
        }
1✔
110

111
        // Parse variable:Label:Label2
112
        parts := strings.Split(strings.TrimSpace(pattern), ":")
1✔
113
        if len(parts) > 0 && parts[0] != "" {
2✔
114
                info.variable = strings.TrimSpace(parts[0])
1✔
115
        }
1✔
116
        for i := 1; i < len(parts); i++ {
2✔
117
                if label := strings.TrimSpace(parts[i]); label != "" {
2✔
118
                        info.labels = append(info.labels, label)
1✔
119
                }
1✔
120
        }
121

122
        return info
1✔
123
}
124

125
// parseProperties parses a Cypher property map like {key1: value1, key2: value2}.
126
//
127
// # Parameters
128
//
129
//   - propsStr: The property map string (with or without braces)
130
//
131
// # Returns
132
//
133
//   - Map of property names to values (converted to Go types)
134
//
135
// # Example
136
//
137
//        parseProperties("{name: 'Alice', age: 30}")
138
//        // Returns: {"name": "Alice", "age": int64(30)}
139
//
140
//        parseProperties("{tags: ['a', 'b'], active: true}")
141
//        // Returns: {"tags": []interface{}{"a", "b"}, "active": true}
142
func (e *StorageExecutor) parseProperties(ctx context.Context, propsStr string) map[string]interface{} {
1✔
143
        props := make(map[string]interface{})
1✔
144

1✔
145
        // Remove outer braces
1✔
146
        propsStr = strings.TrimSpace(propsStr)
1✔
147
        if strings.HasPrefix(propsStr, "{") && strings.HasSuffix(propsStr, "}") {
2✔
148
                propsStr = propsStr[1 : len(propsStr)-1]
1✔
149
        }
1✔
150
        propsStr = strings.TrimSpace(propsStr)
1✔
151

1✔
152
        if propsStr == "" {
2✔
153
                return props
1✔
154
        }
1✔
155

156
        // Parse key-value pairs using a state machine that respects quotes, brackets, and nested structures
157
        pairs := e.splitPropertyPairs(propsStr)
1✔
158

1✔
159
        for _, pair := range pairs {
2✔
160
                colonIdx := strings.Index(pair, ":")
1✔
161
                if colonIdx <= 0 {
2✔
162
                        continue
1✔
163
                }
164

165
                key := normalizePropertyKey(strings.TrimSpace(pair[:colonIdx]))
1✔
166
                valueStr := strings.TrimSpace(pair[colonIdx+1:])
1✔
167

1✔
168
                // Parse the value
1✔
169
                props[key] = e.parsePropertyValue(ctx, valueStr)
1✔
170
        }
171

172
        return props
1✔
173
}
174

175
func normalizePropertyKey(key string) string {
1✔
176
        key = strings.TrimSpace(key)
1✔
177
        if len(key) >= 2 {
2✔
178
                if strings.HasPrefix(key, "`") && strings.HasSuffix(key, "`") {
2✔
179
                        return strings.ReplaceAll(key[1:len(key)-1], "``", "`")
1✔
180
                }
1✔
181
                if (strings.HasPrefix(key, "'") && strings.HasSuffix(key, "'")) ||
1✔
182
                        (strings.HasPrefix(key, "\"") && strings.HasSuffix(key, "\"")) {
2✔
183
                        return key[1 : len(key)-1]
1✔
184
                }
1✔
185
        }
186
        return key
1✔
187
}
188

189
// splitPropertyPairs splits a property string into key:value pairs,
190
// respecting quotes, brackets, and nested braces.
191
//
192
// # Parameters
193
//
194
//   - propsStr: The property pairs string (without outer braces)
195
//
196
// # Returns
197
//
198
//   - Slice of "key: value" strings
199
//
200
// # Example
201
//
202
//        splitPropertyPairs("name: 'Alice', age: 30")
203
//        // Returns: ["name: 'Alice'", "age: 30"]
204
//
205
//        splitPropertyPairs("tags: ['a', 'b'], data: {nested: true}")
206
//        // Returns: ["tags: ['a', 'b']", "data: {nested: true}"]
207
func (e *StorageExecutor) splitPropertyPairs(propsStr string) []string {
1✔
208
        var pairs []string
1✔
209
        var current strings.Builder
1✔
210
        depth := 0 // Track [], {} nesting
1✔
211
        inQuote := false
1✔
212
        quoteChar := rune(0)
1✔
213

1✔
214
        for i, c := range propsStr {
2✔
215
                switch {
1✔
216
                case c == '\'' || c == '"':
1✔
217
                        if !inQuote {
2✔
218
                                inQuote = true
1✔
219
                                quoteChar = c
1✔
220
                        } else if c == quoteChar {
3✔
221
                                // Check for escaped quote (look back for \)
1✔
222
                                escaped := false
1✔
223
                                if i > 0 {
2✔
224
                                        // Count consecutive backslashes before this quote
1✔
225
                                        backslashes := 0
1✔
226
                                        for j := i - 1; j >= 0 && propsStr[j] == '\\'; j-- {
2✔
227
                                                backslashes++
1✔
228
                                        }
1✔
229
                                        escaped = backslashes%2 == 1
1✔
230
                                }
231
                                if !escaped {
2✔
232
                                        inQuote = false
1✔
233
                                }
1✔
234
                        }
235
                        current.WriteRune(c)
1✔
236
                case (c == '[' || c == '{' || c == '(') && !inQuote:
1✔
237
                        depth++
1✔
238
                        current.WriteRune(c)
1✔
239
                case (c == ']' || c == '}' || c == ')') && !inQuote:
1✔
240
                        depth--
1✔
241
                        current.WriteRune(c)
1✔
242
                case c == ',' && !inQuote && depth == 0:
1✔
243
                        if s := strings.TrimSpace(current.String()); s != "" {
2✔
244
                                pairs = append(pairs, s)
1✔
245
                        }
1✔
246
                        current.Reset()
1✔
247
                default:
1✔
248
                        current.WriteRune(c)
1✔
249
                }
250
        }
251

252
        // Add final pair
253
        if s := strings.TrimSpace(current.String()); s != "" {
2✔
254
                pairs = append(pairs, s)
1✔
255
        }
1✔
256

257
        return pairs
1✔
258
}
259

260
// parsePropertyValue parses a single property value string into the appropriate Go type.
261
//
262
// Supported types:
263
//   - null → nil
264
//   - 'string' or "string" → string
265
//   - true/false → bool
266
//   - 123 → int64
267
//   - 1.23 → float64
268
//   - [1, 2, 3] → []interface{}
269
//   - {key: value} → map[string]interface{}
270
//   - function() → evaluated result
271
//
272
// # Parameters
273
//
274
//   - valueStr: The value string to parse
275
//
276
// # Returns
277
//
278
//   - The parsed Go value
279
//
280
// # Example
281
//
282
//        parsePropertyValue("'Alice'")  // "Alice"
283
//        parsePropertyValue("30")       // int64(30)
284
//        parsePropertyValue("true")     // true
285
//        parsePropertyValue("[1, 2]")   // []interface{}{int64(1), int64(2)}
286
func (e *StorageExecutor) parsePropertyValue(ctx context.Context, valueStr string) interface{} {
1✔
287
        valueStr = strings.TrimSpace(valueStr)
1✔
288

1✔
289
        if valueStr == "" {
2✔
290
                return nil
1✔
291
        }
1✔
292

293
        // Handle $param and dotted $param.path references directly so typed values
294
        // survive intact during pattern parsing.
295
        if v, ok := resolveParamPathRef(ctx, valueStr); ok {
2✔
296
                return normalizePropValue(v)
1✔
297
        }
1✔
298
        if v, ok := resolveContextPathRef(ctx, valueStr); ok {
2✔
299
                return normalizePropValue(v)
1✔
300
        }
1✔
301

302
        // Handle bare $param references directly so the typed value (e.g. []string,
303
        // []float64) survives intact. Without this, the param-skip in
304
        // substituteParams leaves "$name" as literal text here and the
305
        // remaining branches would either misparse it or fall through to the
306
        // invalid-value catch-all.
307
        if v, ok := resolveDirectParamRef(ctx, valueStr); ok {
1✔
308
                return normalizePropValue(v)
×
309
        }
×
310
        if v, ok := resolveContextPathRef(ctx, valueStr); ok {
1✔
NEW
311
                return normalizePropValue(v)
×
NEW
312
        }
×
313

314
        // Handle null
315
        if strings.EqualFold(valueStr, "null") {
2✔
316
                return nil
1✔
317
        }
1✔
318

319
        // Handle quoted strings
320
        if len(valueStr) >= 2 {
2✔
321
                first, last := valueStr[0], valueStr[len(valueStr)-1]
1✔
322
                if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
2✔
323
                        if decoded, ok := decodeCypherQuotedString(valueStr); ok {
2✔
324
                                return decoded
1✔
325
                        }
1✔
326
                }
327
        }
328

329
        // Handle booleans
330
        lowerVal := strings.ToLower(valueStr)
1✔
331
        if lowerVal == "true" {
2✔
332
                return true
1✔
333
        }
1✔
334
        if lowerVal == "false" {
2✔
335
                return false
1✔
336
        }
1✔
337

338
        // Handle integers
339
        if intVal, err := strconv.ParseInt(valueStr, 10, 64); err == nil {
2✔
340
                return intVal
1✔
341
        }
1✔
342

343
        // Handle floats
344
        if floatVal, err := strconv.ParseFloat(valueStr, 64); err == nil {
2✔
345
                return floatVal
1✔
346
        }
1✔
347

348
        // Handle arrays
349
        if strings.HasPrefix(valueStr, "[") && strings.HasSuffix(valueStr, "]") {
2✔
350
                return e.parseArrayValue(ctx, valueStr)
1✔
351
        }
1✔
352

353
        // Handle nested maps (rare in properties, but possible)
354
        if strings.HasPrefix(valueStr, "{") && strings.HasSuffix(valueStr, "}") {
2✔
355
                return e.parseProperties(ctx, valueStr)
1✔
356
        }
1✔
357

358
        // Handle function calls like kalman.init(), toUpper('test'), etc.
359
        // A function call has the pattern: name(...) or name.sub.name(...)
360
        if looksLikeFunctionCall(valueStr) {
2✔
361
                result := e.evaluateExpressionWithContext(ctx, valueStr, nil, nil)
1✔
362
                // Only use the result if evaluation succeeded (not returned as original string)
1✔
363
                if result != nil && result != valueStr {
2✔
364
                        return result
1✔
365
                }
1✔
366
        }
367

368
        // Check for malformed values (unquoted colon indicates injection attempt or syntax error)
369
        if strings.Contains(valueStr, ":") && !strings.HasPrefix(valueStr, "{") {
2✔
370
                // Return a special marker that will trigger validation error
1✔
371
                return invalidPropertyValue{raw: valueStr}
1✔
372
        }
1✔
373

374
        // Otherwise return as string (handles unquoted identifiers, etc.)
375
        return valueStr
1✔
376
}
377

378
// invalidPropertyValue marks a property value that failed parsing validation
379
type invalidPropertyValue struct {
380
        raw string
381
}
382

383
// parseArrayValue parses a Cypher array literal like [1, 2, 3] or ['a', 'b', 'c'].
384
//
385
// # Parameters
386
//
387
//   - arrayStr: The array literal string (with brackets)
388
//
389
// # Returns
390
//
391
//   - Slice of parsed values
392
//
393
// # Example
394
//
395
//        parseArrayValue("[1, 2, 3]")      // []interface{}{int64(1), int64(2), int64(3)}
396
//        parseArrayValue("['a', 'b']")    // []interface{}{"a", "b"}
397
//        parseArrayValue("[[1], [2]]")    // []interface{}{[]interface{}{1}, []interface{}{2}}
398
func (e *StorageExecutor) parseArrayValue(ctx context.Context, arrayStr string) []interface{} {
1✔
399
        // Remove brackets
1✔
400
        inner := strings.TrimSpace(arrayStr[1 : len(arrayStr)-1])
1✔
401
        if inner == "" {
2✔
402
                return []interface{}{}
1✔
403
        }
1✔
404

405
        // Split array elements respecting nested structures
406
        elements := e.splitArrayElements(inner)
1✔
407
        result := make([]interface{}, len(elements))
1✔
408

1✔
409
        for i, elem := range elements {
2✔
410
                result[i] = e.parsePropertyValue(ctx, strings.TrimSpace(elem))
1✔
411
        }
1✔
412

413
        return result
1✔
414
}
415

416
// splitArrayElements splits array contents by comma, respecting nested structures and quotes.
417
//
418
// # Parameters
419
//
420
//   - inner: The array contents (without brackets)
421
//
422
// # Returns
423
//
424
//   - Slice of element strings
425
//
426
// # Example
427
//
428
//        splitArrayElements("1, 2, 3")            // ["1", "2", "3"]
429
//        splitArrayElements("'a,b', 'c'")         // ["'a,b'", "'c'"]
430
//        splitArrayElements("[1, 2], [3, 4]")     // ["[1, 2]", "[3, 4]"]
431
func (e *StorageExecutor) splitArrayElements(inner string) []string {
1✔
432
        var elements []string
1✔
433
        var current strings.Builder
1✔
434
        depth := 0
1✔
435
        inQuote := false
1✔
436
        quoteChar := rune(0)
1✔
437

1✔
438
        for i, c := range inner {
2✔
439
                switch {
1✔
440
                case c == '\'' || c == '"':
1✔
441
                        if !inQuote {
2✔
442
                                inQuote = true
1✔
443
                                quoteChar = c
1✔
444
                        } else if c == quoteChar {
3✔
445
                                escaped := false
1✔
446
                                if i > 0 && inner[i-1] == '\\' {
2✔
447
                                        escaped = true
1✔
448
                                }
1✔
449
                                if !escaped {
2✔
450
                                        inQuote = false
1✔
451
                                }
1✔
452
                        }
453
                        current.WriteRune(c)
1✔
454
                case (c == '[' || c == '{') && !inQuote:
1✔
455
                        depth++
1✔
456
                        current.WriteRune(c)
1✔
457
                case (c == ']' || c == '}') && !inQuote:
1✔
458
                        depth--
1✔
459
                        current.WriteRune(c)
1✔
460
                case c == ',' && !inQuote && depth == 0:
1✔
461
                        if s := strings.TrimSpace(current.String()); s != "" {
2✔
462
                                elements = append(elements, s)
1✔
463
                        }
1✔
464
                        current.Reset()
1✔
465
                default:
1✔
466
                        current.WriteRune(c)
1✔
467
                }
468
        }
469

470
        if s := strings.TrimSpace(current.String()); s != "" {
2✔
471
                elements = append(elements, s)
1✔
472
        }
1✔
473

474
        return elements
1✔
475
}
476

477
// looksLikeFunctionCall checks if a string looks like a function call.
478
//
479
// A function call matches: identifier(...) or namespace.function(...)
480
//
481
// # Parameters
482
//
483
//   - s: The string to check
484
//
485
// # Returns
486
//
487
//   - true if the string looks like a function call
488
//
489
// # Example
490
//
491
//        looksLikeFunctionCall("toUpper('test')")     // true
492
//        looksLikeFunctionCall("apoc.coll.sum([1])")  // true
493
//        looksLikeFunctionCall("'not a function'")    // false
494
//        looksLikeFunctionCall("x + y")               // false
495
func looksLikeFunctionCall(s string) bool {
1✔
496
        s = strings.TrimSpace(s)
1✔
497
        if s == "" {
2✔
498
                return false
1✔
499
        }
1✔
500

501
        // Must end with )
502
        if !strings.HasSuffix(s, ")") {
2✔
503
                return false
1✔
504
        }
1✔
505

506
        // Find the opening parenthesis
507
        parenIdx := strings.Index(s, "(")
1✔
508
        if parenIdx <= 0 {
2✔
509
                return false
1✔
510
        }
1✔
511

512
        // The part before ( must be a valid identifier (possibly with dots for namespacing)
513
        name := s[:parenIdx]
1✔
514

1✔
515
        // Allow dots for namespaced functions like apoc.coll.sum
1✔
516
        for i, c := range name {
2✔
517
                if i == 0 {
2✔
518
                        // First char must be letter or underscore
1✔
519
                        if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_') {
2✔
520
                                return false
1✔
521
                        }
1✔
522
                } else {
1✔
523
                        // Subsequent chars can be alphanumeric, underscore, or dot
1✔
524
                        if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '.') {
2✔
525
                                return false
1✔
526
                        }
1✔
527
                }
528
        }
529

530
        return true
1✔
531
}
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