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

orneryd / NornicDB / 26642077920

29 May 2026 02:07PM UTC coverage: 86.886% (+0.03%) from 86.856%
26642077920

push

github

orneryd
fix(cypher): fixing $ dotted param parsing and skipping the simple-where cache for parameterized values. adding more unit test coverage

74 of 80 new or added lines in 4 files covered. (92.5%)

50 existing lines in 10 files now uncovered.

134089 of 154327 relevant lines covered (86.89%)

1.01 hits per line

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

99.19
/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

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

308
        // Handle null
309
        if strings.EqualFold(valueStr, "null") {
2✔
310
                return nil
1✔
311
        }
1✔
312

313
        // Handle quoted strings
314
        if len(valueStr) >= 2 {
2✔
315
                first, last := valueStr[0], valueStr[len(valueStr)-1]
1✔
316
                if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
2✔
317
                        if decoded, ok := decodeCypherQuotedString(valueStr); ok {
2✔
318
                                return decoded
1✔
319
                        }
1✔
320
                }
321
        }
322

323
        // Handle booleans
324
        lowerVal := strings.ToLower(valueStr)
1✔
325
        if lowerVal == "true" {
2✔
326
                return true
1✔
327
        }
1✔
328
        if lowerVal == "false" {
2✔
329
                return false
1✔
330
        }
1✔
331

332
        // Handle integers
333
        if intVal, err := strconv.ParseInt(valueStr, 10, 64); err == nil {
2✔
334
                return intVal
1✔
335
        }
1✔
336

337
        // Handle floats
338
        if floatVal, err := strconv.ParseFloat(valueStr, 64); err == nil {
2✔
339
                return floatVal
1✔
340
        }
1✔
341

342
        // Handle arrays
343
        if strings.HasPrefix(valueStr, "[") && strings.HasSuffix(valueStr, "]") {
2✔
344
                return e.parseArrayValue(ctx, valueStr)
1✔
345
        }
1✔
346

347
        // Handle nested maps (rare in properties, but possible)
348
        if strings.HasPrefix(valueStr, "{") && strings.HasSuffix(valueStr, "}") {
2✔
349
                return e.parseProperties(ctx, valueStr)
1✔
350
        }
1✔
351

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

362
        // Check for malformed values (unquoted colon indicates injection attempt or syntax error)
363
        if strings.Contains(valueStr, ":") && !strings.HasPrefix(valueStr, "{") {
2✔
364
                // Return a special marker that will trigger validation error
1✔
365
                return invalidPropertyValue{raw: valueStr}
1✔
366
        }
1✔
367

368
        // Otherwise return as string (handles unquoted identifiers, etc.)
369
        return valueStr
1✔
370
}
371

372
// invalidPropertyValue marks a property value that failed parsing validation
373
type invalidPropertyValue struct {
374
        raw string
375
}
376

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

399
        // Split array elements respecting nested structures
400
        elements := e.splitArrayElements(inner)
1✔
401
        result := make([]interface{}, len(elements))
1✔
402

1✔
403
        for i, elem := range elements {
2✔
404
                result[i] = e.parsePropertyValue(ctx, strings.TrimSpace(elem))
1✔
405
        }
1✔
406

407
        return result
1✔
408
}
409

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

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

464
        if s := strings.TrimSpace(current.String()); s != "" {
2✔
465
                elements = append(elements, s)
1✔
466
        }
1✔
467

468
        return elements
1✔
469
}
470

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

495
        // Must end with )
496
        if !strings.HasSuffix(s, ")") {
2✔
497
                return false
1✔
498
        }
1✔
499

500
        // Find the opening parenthesis
501
        parenIdx := strings.Index(s, "(")
1✔
502
        if parenIdx <= 0 {
2✔
503
                return false
1✔
504
        }
1✔
505

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

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

524
        return true
1✔
525
}
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