cypher.go raw
1 package neo4j
2
3 import (
4 "context"
5 "fmt"
6 "regexp"
7 "strings"
8
9 "github.com/neo4j/neo4j-go-driver/v5/neo4j/dbtype"
10 )
11
12 // writeKeywords matches Cypher write operations. This is a safety net for the
13 // owner-only endpoint, not a security boundary.
14 var writeKeywords = regexp.MustCompile(`(?i)\b(CREATE|MERGE|SET|DELETE|REMOVE|DETACH|DROP|CALL)\b`)
15
16 // stripCypherComments removes single-line (//) and block (/* */) comments
17 // from a Cypher query string so they don't trigger false write-keyword matches.
18 func stripCypherComments(q string) string {
19 // Block comments
20 for {
21 start := strings.Index(q, "/*")
22 if start < 0 {
23 break
24 }
25 end := strings.Index(q[start+2:], "*/")
26 if end < 0 {
27 q = q[:start]
28 break
29 }
30 q = q[:start] + q[start+2+end+2:]
31 }
32 // Single-line comments
33 lines := strings.Split(q, "\n")
34 for i, line := range lines {
35 if idx := strings.Index(line, "//"); idx >= 0 {
36 lines[i] = line[:idx]
37 }
38 }
39 return strings.Join(lines, "\n")
40 }
41
42 // validateReadOnly checks that a Cypher query contains no write operations.
43 func validateReadOnly(cypher string) error {
44 stripped := stripCypherComments(cypher)
45 if writeKeywords.MatchString(stripped) {
46 return fmt.Errorf("query contains write operations; only read-only queries are allowed")
47 }
48 return nil
49 }
50
51 // ExecuteCypherRead executes a read-only Cypher query and returns the results
52 // as a slice of maps. Each map represents one record with column names as keys.
53 // This implements the store.CypherExecutor interface.
54 func (n *N) ExecuteCypherRead(ctx context.Context, cypher string, params map[string]any) ([]map[string]any, error) {
55 if err := validateReadOnly(cypher); err != nil {
56 return nil, err
57 }
58
59 result, err := n.ExecuteRead(ctx, cypher, params)
60 if err != nil {
61 return nil, fmt.Errorf("cypher read failed: %w", err)
62 }
63
64 records := make([]map[string]any, 0, result.Len())
65 for result.Next(ctx) {
66 rec := result.Record()
67 row := make(map[string]any, len(rec.Keys))
68 for _, key := range rec.Keys {
69 val, _ := rec.Get(key)
70 row[key] = convertNeo4jValue(val)
71 }
72 records = append(records, row)
73 }
74 return records, nil
75 }
76
77 // convertNeo4jValue converts Neo4j driver types (Node, Relationship, Path)
78 // into JSON-serializable plain maps and slices.
79 func convertNeo4jValue(v any) any {
80 switch val := v.(type) {
81 case dbtype.Node:
82 m := map[string]any{
83 "_type": "node",
84 "_id": val.ElementId,
85 "_labels": val.Labels,
86 }
87 for k, pv := range val.Props {
88 m[k] = convertNeo4jValue(pv)
89 }
90 return m
91 case dbtype.Relationship:
92 return map[string]any{
93 "_type": "relationship",
94 "_id": val.ElementId,
95 "_relType": val.Type,
96 "_startId": val.StartElementId,
97 "_endId": val.EndElementId,
98 "_props": convertNeo4jProps(val.Props),
99 }
100 case dbtype.Path:
101 nodes := make([]any, len(val.Nodes))
102 for i, node := range val.Nodes {
103 nodes[i] = convertNeo4jValue(node)
104 }
105 rels := make([]any, len(val.Relationships))
106 for i, rel := range val.Relationships {
107 rels[i] = convertNeo4jValue(rel)
108 }
109 return map[string]any{
110 "_type": "path",
111 "_nodes": nodes,
112 "_relationships": rels,
113 }
114 case []any:
115 out := make([]any, len(val))
116 for i, item := range val {
117 out[i] = convertNeo4jValue(item)
118 }
119 return out
120 case map[string]any:
121 return convertNeo4jProps(val)
122 default:
123 return v
124 }
125 }
126
127 func convertNeo4jProps(props map[string]any) map[string]any {
128 out := make(map[string]any, len(props))
129 for k, v := range props {
130 out[k] = convertNeo4jValue(v)
131 }
132 return out
133 }
134
135 // Ensure N implements CypherExecutor at compile time.
136 var _ interface {
137 ExecuteCypherRead(ctx context.Context, cypher string, params map[string]any) ([]map[string]any, error)
138 } = (*N)(nil)
139