summaryrefslogtreecommitdiffstats
path: root/internal/storage/query.go
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-02-13 17:43:37 -0800
committerbndw <ben@bdw.to>2026-02-13 17:43:37 -0800
commit5fcf5bd1fa5b2e707cea82c4652ea65c3c113c1a (patch)
tree5bbde8b406f5ecd09361298bbf86edde52f52984 /internal/storage/query.go
parenta6502c0888613bd0377a25e43de8ae306c4de4d7 (diff)
feat: add query layer with Nostr filter to SQL conversion
Query implementation: - QueryEvents method with filter support - Full NIP-01 filter support (ids, authors, kinds, tags, since, until, limit) - ID and pubkey prefix matching - Tag filtering using SQLite JSON functions - Multiple filter UNION support - DESC ordering by created_at - Optional canonical JSON inclusion 23 tests passing, 1322 total lines
Diffstat (limited to 'internal/storage/query.go')
-rw-r--r--internal/storage/query.go185
1 files changed, 185 insertions, 0 deletions
diff --git a/internal/storage/query.go b/internal/storage/query.go
new file mode 100644
index 0000000..29a2a4c
--- /dev/null
+++ b/internal/storage/query.go
@@ -0,0 +1,185 @@
1package storage
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 "google.golang.org/protobuf/proto"
9
10 pb "northwest.io/nostr-grpc/api/nostr/v1"
11)
12
13type QueryOptions struct {
14 IncludeCanonical bool
15 Limit int32
16}
17
18func (s *Storage) QueryEvents(ctx context.Context, filters []*pb.Filter, opts *QueryOptions) ([]*pb.Event, error) {
19 if len(filters) == 0 {
20 return nil, nil
21 }
22
23 if opts == nil {
24 opts = &QueryOptions{Limit: 100}
25 }
26
27 query, args := buildQuery(filters, opts)
28
29 rows, err := s.db.QueryContext(ctx, query, args...)
30 if err != nil {
31 return nil, fmt.Errorf("query failed: %w", err)
32 }
33 defer rows.Close()
34
35 var events []*pb.Event
36 for rows.Next() {
37 var eventBytes []byte
38 var compressedJSON []byte
39 var createdAt int64
40
41 if opts.IncludeCanonical {
42 if err := rows.Scan(&eventBytes, &compressedJSON, &createdAt); err != nil {
43 return nil, fmt.Errorf("scan failed: %w", err)
44 }
45 } else {
46 if err := rows.Scan(&eventBytes, &createdAt); err != nil {
47 return nil, fmt.Errorf("scan failed: %w", err)
48 }
49 }
50
51 event := &pb.Event{}
52 if err := proto.Unmarshal(eventBytes, event); err != nil {
53 return nil, fmt.Errorf("unmarshal failed: %w", err)
54 }
55
56 if opts.IncludeCanonical && compressedJSON != nil {
57 canonicalJSON, err := decompressJSON(compressedJSON)
58 if err != nil {
59 return nil, fmt.Errorf("decompress failed: %w", err)
60 }
61 event.CanonicalJson = canonicalJSON
62 }
63
64 events = append(events, event)
65 }
66
67 if err := rows.Err(); err != nil {
68 return nil, fmt.Errorf("rows iteration failed: %w", err)
69 }
70
71 return events, nil
72}
73
74func buildQuery(filters []*pb.Filter, opts *QueryOptions) (string, []interface{}) {
75 selectFields := "event_data, created_at"
76 if opts.IncludeCanonical {
77 selectFields = "event_data, canonical_json, created_at"
78 }
79
80 var unions []string
81 var args []interface{}
82
83 for _, filter := range filters {
84 clause, filterArgs := buildWhereClause(filter)
85 subQuery := fmt.Sprintf("SELECT %s FROM events WHERE deleted = 0 AND (%s)", selectFields, clause)
86 unions = append(unions, subQuery)
87 args = append(args, filterArgs...)
88 }
89
90 query := strings.Join(unions, " UNION ")
91 query += " ORDER BY created_at DESC"
92
93 if opts.Limit > 0 {
94 query += fmt.Sprintf(" LIMIT %d", opts.Limit)
95 }
96
97 return query, args
98}
99
100func buildWhereClause(filter *pb.Filter) (string, []interface{}) {
101 var conditions []string
102 var args []interface{}
103
104 if len(filter.Ids) > 0 {
105 conditions = append(conditions, buildPrefixCondition("id", filter.Ids, &args))
106 }
107
108 if len(filter.Authors) > 0 {
109 conditions = append(conditions, buildPrefixCondition("pubkey", filter.Authors, &args))
110 }
111
112 if len(filter.Kinds) > 0 {
113 placeholders := make([]string, len(filter.Kinds))
114 for i, kind := range filter.Kinds {
115 placeholders[i] = "?"
116 args = append(args, kind)
117 }
118 conditions = append(conditions, fmt.Sprintf("kind IN (%s)", strings.Join(placeholders, ",")))
119 }
120
121 if filter.Since != nil && *filter.Since > 0 {
122 conditions = append(conditions, "created_at >= ?")
123 args = append(args, *filter.Since)
124 }
125
126 if filter.Until != nil && *filter.Until > 0 {
127 conditions = append(conditions, "created_at <= ?")
128 args = append(args, *filter.Until)
129 }
130
131 if len(filter.ETags) > 0 {
132 conditions = append(conditions, buildTagCondition("e", filter.ETags, &args))
133 }
134
135 if len(filter.PTags) > 0 {
136 conditions = append(conditions, buildTagCondition("p", filter.PTags, &args))
137 }
138
139 for tagName, tagFilter := range filter.TagFilters {
140 if len(tagFilter.Values) > 0 {
141 conditions = append(conditions, buildTagCondition(tagName, tagFilter.Values, &args))
142 }
143 }
144
145 if len(conditions) == 0 {
146 return "1=1", args
147 }
148
149 return strings.Join(conditions, " AND "), args
150}
151
152func buildPrefixCondition(column string, values []string, args *[]interface{}) string {
153 var orConditions []string
154
155 for _, val := range values {
156 if len(val) == 64 {
157 orConditions = append(orConditions, column+" = ?")
158 *args = append(*args, val)
159 } else {
160 orConditions = append(orConditions, column+" LIKE ?")
161 *args = append(*args, val+"%")
162 }
163 }
164
165 if len(orConditions) == 1 {
166 return orConditions[0]
167 }
168
169 return "(" + strings.Join(orConditions, " OR ") + ")"
170}
171
172func buildTagCondition(tagName string, values []string, args *[]interface{}) string {
173 var orConditions []string
174
175 for _, val := range values {
176 orConditions = append(orConditions, "EXISTS (SELECT 1 FROM json_each(tags) WHERE json_extract(value, '$[0]') = ? AND json_extract(value, '$[1]') = ?)")
177 *args = append(*args, tagName, val)
178 }
179
180 if len(orConditions) == 1 {
181 return orConditions[0]
182 }
183
184 return "(" + strings.Join(orConditions, " OR ") + ")"
185}