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

freeeve / tinykvs / 21101309417

17 Jan 2026 09:42PM UTC coverage: 68.627% (-4.3%) from 72.975%
21101309417

push

github

freeeve
feat: add ORDER BY support and box-style table formatting

New features:
- ORDER BY support in SQL queries (ASC/DESC, multiple columns)
- Box-style table formatting with Unicode borders
- GitHub Actions CI for multi-platform releases (linux/darwin/windows × amd64/arm64)
- Build script with ldflags for version embedding

Improvements:
- Split shell.go into logical modules (shell_select.go, shell_write.go,
  shell_csv.go, shell_sql.go, shell_sort.go)
- Version string no longer shows redundant commit hash
- Reorganized tests into corresponding test files
- Added batch_parallel.go for parallel batch operations
- Test coverage improvements

975 of 1677 new or added lines in 11 files covered. (58.14%)

8 existing lines in 3 files now uncovered.

5180 of 7548 relevant lines covered (68.63%)

430934.64 hits per line

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

68.67
/cmd/tinykvs/shell_select.go
1
package main
2

3
import (
4
        "encoding/json"
5
        "fmt"
6
        "math"
7
        "os"
8
        "os/signal"
9
        "strconv"
10
        "strings"
11
        "sync/atomic"
12
        "syscall"
13
        "time"
14

15
        "github.com/blastrain/vitess-sqlparser/sqlparser"
16
        "github.com/freeeve/tinykvs"
17
        "github.com/vmihailenco/msgpack/v5"
18
)
19

20
// aggType represents an aggregation function type.
21
type aggType int
22

23
const (
24
        aggCount aggType = iota
25
        aggSum
26
        aggAvg
27
        aggMin
28
        aggMax
29
)
30

31
// aggregator holds state for a streaming aggregation.
32
type aggregator struct {
33
        typ   aggType
34
        field string // field path for sum/avg/min/max (empty for count)
35
        alias string // display name
36

37
        // streaming state
38
        count    int64
39
        sum      float64
40
        min      float64
41
        max      float64
42
        hasValue bool
43
}
44

45
func (a *aggregator) update(val tinykvs.Value) {
44✔
46
        a.count++
44✔
47

44✔
48
        if a.field == "" {
57✔
49
                return // count() doesn't need field extraction
13✔
50
        }
13✔
51

52
        // Extract numeric value from field
53
        num, ok := a.extractNumeric(val)
31✔
54
        if !ok {
31✔
NEW
55
                return
×
NEW
56
        }
×
57

58
        a.sum += num
31✔
59
        if !a.hasValue {
40✔
60
                a.min = num
9✔
61
                a.max = num
9✔
62
                a.hasValue = true
9✔
63
        } else {
31✔
64
                if num < a.min {
24✔
65
                        a.min = num
2✔
66
                }
2✔
67
                if num > a.max {
40✔
68
                        a.max = num
18✔
69
                }
18✔
70
        }
71
}
72

73
func (a *aggregator) extractNumeric(val tinykvs.Value) (float64, bool) {
31✔
74
        // For non-record types, use the value directly
31✔
75
        if a.field == "" {
31✔
NEW
76
                switch val.Type {
×
NEW
77
                case tinykvs.ValueTypeInt64:
×
NEW
78
                        return float64(val.Int64), true
×
NEW
79
                case tinykvs.ValueTypeFloat64:
×
NEW
80
                        return val.Float64, true
×
NEW
81
                default:
×
NEW
82
                        return 0, false
×
83
                }
84
        }
85

86
        // For record/msgpack types, extract the field
87
        var record map[string]any
31✔
88
        switch val.Type {
31✔
NEW
89
        case tinykvs.ValueTypeRecord:
×
NEW
90
                record = val.Record
×
91
        case tinykvs.ValueTypeMsgpack:
31✔
92
                if err := msgpack.Unmarshal(val.Bytes, &record); err != nil {
31✔
NEW
93
                        return 0, false
×
NEW
94
                }
×
NEW
95
        default:
×
NEW
96
                return 0, false
×
97
        }
98
        if record == nil {
31✔
NEW
99
                return 0, false
×
NEW
100
        }
×
101

102
        fieldVal, ok := extractNestedField(record, a.field)
31✔
103
        if !ok {
31✔
NEW
104
                return 0, false
×
NEW
105
        }
×
106

107
        switch v := fieldVal.(type) {
31✔
NEW
108
        case int:
×
NEW
109
                return float64(v), true
×
110
        case int8:
11✔
111
                return float64(v), true
11✔
NEW
112
        case int16:
×
NEW
113
                return float64(v), true
×
NEW
114
        case int32:
×
NEW
115
                return float64(v), true
×
NEW
116
        case int64:
×
NEW
117
                return float64(v), true
×
NEW
118
        case uint:
×
NEW
119
                return float64(v), true
×
NEW
120
        case uint8:
×
NEW
121
                return float64(v), true
×
NEW
122
        case uint16:
×
NEW
123
                return float64(v), true
×
NEW
124
        case uint32:
×
NEW
125
                return float64(v), true
×
NEW
126
        case uint64:
×
NEW
127
                return float64(v), true
×
NEW
128
        case float32:
×
NEW
129
                return float64(v), true
×
130
        case float64:
20✔
131
                return v, true
20✔
NEW
132
        default:
×
NEW
133
                return 0, false
×
134
        }
135
}
136

137
func (a *aggregator) result() string {
15✔
138
        switch a.typ {
15✔
139
        case aggCount:
4✔
140
                return fmt.Sprintf("%d", a.count)
4✔
141
        case aggSum:
4✔
142
                if !a.hasValue {
5✔
143
                        return "NULL"
1✔
144
                }
1✔
145
                return formatNumber(a.sum)
3✔
146
        case aggAvg:
3✔
147
                if a.count == 0 || !a.hasValue {
4✔
148
                        return "NULL"
1✔
149
                }
1✔
150
                return formatNumber(a.sum / float64(a.count))
2✔
151
        case aggMin:
2✔
152
                if !a.hasValue {
2✔
NEW
153
                        return "NULL"
×
NEW
154
                }
×
155
                return formatNumber(a.min)
2✔
156
        case aggMax:
2✔
157
                if !a.hasValue {
2✔
NEW
158
                        return "NULL"
×
NEW
159
                }
×
160
                return formatNumber(a.max)
2✔
NEW
161
        default:
×
NEW
162
                return "NULL"
×
163
        }
164
}
165

166
func formatNumber(f float64) string {
9✔
167
        if f == math.Trunc(f) {
17✔
168
                return fmt.Sprintf("%.0f", f)
8✔
169
        }
8✔
170
        return fmt.Sprintf("%g", f)
1✔
171
}
172

173
// valueFilter represents a filter condition on a value field
174
type valueFilter struct {
175
        field    string // e.g., "ttl", "name.first"
176
        operator string // "=", "like", ">", "<", ">=", "<="
177
        value    string // the comparison value
178
}
179

NEW
180
func (vf *valueFilter) matches(val tinykvs.Value) bool {
×
NEW
181
        // Extract the record
×
NEW
182
        var record map[string]any
×
NEW
183
        switch val.Type {
×
NEW
184
        case tinykvs.ValueTypeRecord:
×
NEW
185
                record = val.Record
×
NEW
186
        case tinykvs.ValueTypeMsgpack:
×
NEW
187
                if err := msgpack.Unmarshal(val.Bytes, &record); err != nil {
×
NEW
188
                        return false
×
NEW
189
                }
×
NEW
190
        default:
×
NEW
191
                return false
×
192
        }
193

194
        // Get field value
NEW
195
        fieldVal, ok := extractNestedField(record, vf.field)
×
NEW
196
        if !ok {
×
NEW
197
                return false
×
NEW
198
        }
×
199

NEW
200
        fieldStr := fmt.Sprintf("%v", fieldVal)
×
NEW
201

×
NEW
202
        switch vf.operator {
×
NEW
203
        case "=":
×
NEW
204
                return fieldStr == vf.value
×
NEW
205
        case "!=", "<>":
×
NEW
206
                return fieldStr != vf.value
×
NEW
207
        case "like":
×
NEW
208
                // Only prefix matching supported
×
NEW
209
                if strings.HasSuffix(vf.value, "%") {
×
NEW
210
                        prefix := vf.value[:len(vf.value)-1]
×
NEW
211
                        return strings.HasPrefix(fieldStr, prefix)
×
NEW
212
                }
×
NEW
213
                return fieldStr == vf.value
×
NEW
214
        case ">":
×
NEW
215
                return fieldStr > vf.value
×
NEW
216
        case "<":
×
NEW
217
                return fieldStr < vf.value
×
NEW
218
        case ">=":
×
NEW
219
                return fieldStr >= vf.value
×
NEW
220
        case "<=":
×
NEW
221
                return fieldStr <= vf.value
×
NEW
222
        default:
×
NEW
223
                return true
×
224
        }
225
}
226

227
func (s *Shell) handleSelect(stmt *sqlparser.Select, orderBy []SortOrder) {
94✔
228
        // Parse WHERE clause
94✔
229
        var keyEquals string
94✔
230
        var keyPrefix string
94✔
231
        var keyStart, keyEnd string
94✔
232
        var valueFilters []*valueFilter
94✔
233
        limit := 100 // default limit
94✔
234

94✔
235
        // Parse SELECT fields and aggregates
94✔
236
        var fields []string
94✔
237
        var aggs []*aggregator
94✔
238
        for _, expr := range stmt.SelectExprs {
202✔
239
                switch e := expr.(type) {
108✔
240
                case *sqlparser.AliasedExpr:
46✔
241
                        // Check for aggregate functions
46✔
242
                        if funcExpr, ok := e.Expr.(*sqlparser.FuncExpr); ok {
61✔
243
                                agg := parseAggregateFunc(funcExpr)
15✔
244
                                if agg != nil {
30✔
245
                                        aggs = append(aggs, agg)
15✔
246
                                        continue
15✔
247
                                }
248
                        }
249

250
                        // Check for field access (v.name, v.address.city)
251
                        if col, ok := e.Expr.(*sqlparser.ColName); ok {
62✔
252
                                qualifier := strings.ToLower(col.Qualifier.Name.String())
31✔
253
                                fieldName := col.Name.String()
31✔
254

31✔
255
                                if qualifier == "v" {
58✔
256
                                        // Direct v.field or v.`nested.path` syntax
27✔
257
                                        fields = append(fields, fieldName)
27✔
258
                                } else if qualifier != "" && qualifier != "kv" {
35✔
259
                                        // Parser split v.address.city into qualifier="address", name="city"
4✔
260
                                        // Reconstruct as dotted path: "address.city"
4✔
261
                                        fields = append(fields, qualifier+"."+fieldName)
4✔
262
                                }
4✔
263
                        }
264
                case *sqlparser.StarExpr:
62✔
265
                        // SELECT * - no specific fields
62✔
266
                        fields = nil
62✔
267
                }
268
        }
269

270
        // Parse LIMIT
271
        if stmt.Limit != nil {
98✔
272
                if stmt.Limit.Rowcount != nil {
8✔
273
                        if val, ok := stmt.Limit.Rowcount.(*sqlparser.SQLVal); ok {
8✔
274
                                if n, err := strconv.Atoi(string(val.Val)); err == nil {
8✔
275
                                        limit = n
4✔
276
                                }
4✔
277
                        }
278
                }
279
        }
280

281
        // Parse WHERE
282
        if stmt.Where != nil {
174✔
283
                s.parseWhere(stmt.Where.Expr, &keyEquals, &keyPrefix, &keyStart, &keyEnd, &valueFilters)
80✔
284
        }
80✔
285

286
        isAggregate := len(aggs) > 0
94✔
287
        hasOrderBy := len(orderBy) > 0
94✔
288

94✔
289
        // Set up headers based on fields
94✔
290
        var headers []string
94✔
291
        if len(fields) > 0 {
117✔
292
                headers = append([]string{"k"}, fields...)
23✔
293
        } else {
94✔
294
                headers = []string{"k", "v"}
71✔
295
        }
71✔
296

297
        // Track progress for slow queries
298
        var scanned int64
94✔
299
        var blocksLoaded int64
94✔
300
        var matchCount int
94✔
301
        var lastProgress time.Time
94✔
302
        hasValueFilters := len(valueFilters) > 0
94✔
303
        startTime := time.Now()
94✔
304

94✔
305
        // Buffer rows for table output
94✔
306
        var bufferedRows [][]string
94✔
307

94✔
308
        // Set up Ctrl+C handler to cancel scan
94✔
309
        var interrupted int32
94✔
310
        sigChan := make(chan os.Signal, 1)
94✔
311
        signal.Notify(sigChan, syscall.SIGINT)
94✔
312
        go func() {
188✔
313
                <-sigChan
94✔
314
                atomic.StoreInt32(&interrupted, 1)
94✔
315
        }()
94✔
316
        defer signal.Stop(sigChan)
94✔
317

94✔
318
        // Progress callback for scan stats
94✔
319
        progressCallback := func(stats tinykvs.ScanStats) bool {
94✔
NEW
320
                if atomic.LoadInt32(&interrupted) != 0 {
×
NEW
321
                        return false
×
NEW
322
                }
×
NEW
323
                blocksLoaded = stats.BlocksLoaded
×
NEW
324
                if time.Since(lastProgress) > time.Second {
×
NEW
325
                        elapsed := time.Since(startTime)
×
NEW
326
                        rate := int64(float64(stats.KeysExamined) / elapsed.Seconds())
×
NEW
327
                        fmt.Fprintf(os.Stderr, "\rScanned %s keys (%s blocks) in %s (%s keys/sec)...    ",
×
NEW
328
                                formatIntCommas(stats.KeysExamined), formatIntCommas(blocksLoaded),
×
NEW
329
                                formatDuration(elapsed), formatIntCommas(rate))
×
NEW
330
                        lastProgress = time.Now()
×
NEW
331
                }
×
NEW
332
                return true
×
333
        }
334

335
        // Callback for processing each row
336
        processRow := func(key []byte, val tinykvs.Value) bool {
253✔
337
                if atomic.LoadInt32(&interrupted) != 0 {
159✔
NEW
338
                        return false
×
NEW
339
                }
×
340
                scanned++
159✔
341

159✔
342
                // Show progress every second for queries with value filters
159✔
343
                if hasValueFilters && time.Since(lastProgress) > time.Second {
159✔
NEW
344
                        elapsed := time.Since(startTime)
×
NEW
345
                        rate := int64(float64(scanned) / elapsed.Seconds())
×
NEW
346
                        fmt.Fprintf(os.Stderr, "\rScanned %s keys (%s blocks) in %s (%s keys/sec), found %d matches...    ",
×
NEW
347
                                formatIntCommas(scanned), formatIntCommas(blocksLoaded),
×
NEW
348
                                formatDuration(elapsed), formatIntCommas(rate), matchCount)
×
NEW
349
                        lastProgress = time.Now()
×
NEW
350
                }
×
351

352
                // For ORDER BY, we need all matching rows before applying limit
353
                if !isAggregate && !hasOrderBy && matchCount >= limit {
161✔
354
                        return false
2✔
355
                }
2✔
356

357
                // Apply value filters
358
                for _, vf := range valueFilters {
157✔
NEW
359
                        if !vf.matches(val) {
×
NEW
360
                                return true // skip this row, continue scanning
×
NEW
361
                        }
×
362
                }
363

364
                if isAggregate {
189✔
365
                        for _, agg := range aggs {
76✔
366
                                agg.update(val)
44✔
367
                        }
44✔
368
                } else {
125✔
369
                        // Buffer rows for table output
125✔
370
                        row := extractRowFields(key, val, fields)
125✔
371
                        bufferedRows = append(bufferedRows, row)
125✔
372
                        matchCount++
125✔
373
                }
125✔
374
                return true
157✔
375
        }
376

377
        var err error
94✔
378
        var scanErr error
94✔
379

94✔
380
        // Wrap processRow to catch any panics
94✔
381
        safeProcessRow := func(key []byte, val tinykvs.Value) bool {
253✔
382
                defer func() {
318✔
383
                        if r := recover(); r != nil {
159✔
NEW
384
                                scanErr = fmt.Errorf("panic processing key %x: %v", key[:min(8, len(key))], r)
×
NEW
385
                        }
×
386
                }()
387
                return processRow(key, val)
159✔
388
        }
389

390
        if keyEquals != "" {
155✔
391
                // Point lookup
61✔
392
                val, e := s.store.Get([]byte(keyEquals))
61✔
393
                if e == tinykvs.ErrKeyNotFound {
66✔
394
                        if isAggregate {
6✔
395
                                printAggregateResults(aggs)
1✔
396
                        } else {
5✔
397
                                printTable(headers, nil)
4✔
398
                                fmt.Printf("(0 rows)\n")
4✔
399
                        }
4✔
400
                        return
5✔
401
                }
402
                if e != nil {
57✔
403
                        fmt.Printf("Error: %v\n", e)
1✔
404
                        return
1✔
405
                }
1✔
406
                safeProcessRow([]byte(keyEquals), val)
55✔
407
        } else if keyPrefix != "" {
44✔
408
                var stats tinykvs.ScanStats
11✔
409
                stats, err = s.store.ScanPrefixWithStats([]byte(keyPrefix), safeProcessRow, progressCallback)
11✔
410
                blocksLoaded = stats.BlocksLoaded
11✔
411
        } else if keyStart != "" && keyEnd != "" {
37✔
412
                err = s.store.ScanRange([]byte(keyStart), []byte(keyEnd), safeProcessRow)
4✔
413
        } else {
22✔
414
                var stats tinykvs.ScanStats
18✔
415
                stats, err = s.store.ScanPrefixWithStats(nil, safeProcessRow, progressCallback)
18✔
416
                blocksLoaded = stats.BlocksLoaded
18✔
417
        }
18✔
418

419
        // Clear progress line
420
        if (hasValueFilters || scanned > 10000) && scanned > 0 {
88✔
NEW
421
                fmt.Fprintf(os.Stderr, "\r%s\r", strings.Repeat(" ", 80))
×
NEW
422
        }
×
423

424
        wasInterrupted := atomic.LoadInt32(&interrupted) != 0
88✔
425
        if wasInterrupted {
88✔
NEW
426
                fmt.Fprintf(os.Stderr, "\r%s\r", strings.Repeat(" ", 80))
×
NEW
427
                fmt.Println("^C")
×
NEW
428
        }
×
429

430
        if scanErr != nil {
88✔
NEW
431
                fmt.Printf("Scan error: %v\n", scanErr)
×
NEW
432
                return
×
NEW
433
        }
×
434
        if err != nil {
91✔
435
                fmt.Printf("Error: %v\n", err)
3✔
436
                return
3✔
437
        }
3✔
438

439
        elapsed := time.Since(startTime)
85✔
440

85✔
441
        if isAggregate {
93✔
442
                printAggregateResults(aggs)
8✔
443
        } else {
85✔
444
                // Sort if ORDER BY was specified
77✔
445
                if hasOrderBy {
77✔
NEW
446
                        SortRows(headers, bufferedRows, orderBy)
×
NEW
447
                }
×
448

449
                // Apply limit after sorting (for ORDER BY) or just cap results
450
                if len(bufferedRows) > limit {
77✔
NEW
451
                        bufferedRows = bufferedRows[:limit]
×
NEW
452
                }
×
453
                matchCount = len(bufferedRows)
77✔
454

77✔
455
                // Print table with box formatting
77✔
456
                printTable(headers, bufferedRows)
77✔
457
        }
458

459
        // Show stats
460
        if scanned > 0 || blocksLoaded > 0 {
164✔
461
                rate := float64(scanned) / elapsed.Seconds()
79✔
462
                if wasInterrupted {
79✔
NEW
463
                        fmt.Printf("(%d rows) - interrupted, scanned %s keys (%s blocks) in %s (%s keys/sec)\n",
×
NEW
464
                                matchCount, formatIntCommas(scanned),
×
NEW
465
                                formatIntCommas(blocksLoaded), formatDuration(elapsed), formatIntCommas(int64(rate)))
×
466
                } else {
79✔
467
                        fmt.Printf("(%d rows) scanned %s keys, %s blocks, %s\n",
79✔
468
                                matchCount, formatIntCommas(scanned),
79✔
469
                                formatIntCommas(blocksLoaded), formatDuration(elapsed))
79✔
470
                }
79✔
471
        } else {
6✔
472
                fmt.Printf("(%d rows)\n", matchCount)
6✔
473
        }
6✔
474
}
475

476
// printStreamingHeader prints column headers for streaming output
NEW
477
func printStreamingHeader(headers []string) {
×
NEW
478
        for i, h := range headers {
×
NEW
479
                if i > 0 {
×
NEW
480
                        fmt.Print("\t")
×
NEW
481
                }
×
NEW
482
                fmt.Print(h)
×
483
        }
NEW
484
        fmt.Println()
×
NEW
485
        // Print separator
×
NEW
486
        for i, h := range headers {
×
NEW
487
                if i > 0 {
×
NEW
488
                        fmt.Print("\t")
×
NEW
489
                }
×
NEW
490
                fmt.Print(strings.Repeat("-", len(h)))
×
491
        }
NEW
492
        fmt.Println()
×
493
}
494

495
// printStreamingRow prints a single row for streaming output
NEW
496
func printStreamingRow(row []string) {
×
NEW
497
        for i, cell := range row {
×
NEW
498
                if i > 0 {
×
NEW
499
                        fmt.Print("\t")
×
NEW
500
                }
×
501
                // Truncate long values
NEW
502
                if len(cell) > 60 {
×
NEW
503
                        fmt.Print(cell[:57] + "...")
×
NEW
504
                } else {
×
NEW
505
                        fmt.Print(cell)
×
NEW
506
                }
×
507
        }
NEW
508
        fmt.Println()
×
509
}
510

511
// printTable prints rows in DuckDB-style box format
512
func printTable(headers []string, rows [][]string) {
81✔
513
        if len(headers) == 0 {
81✔
NEW
514
                return
×
NEW
515
        }
×
516

517
        // Calculate column widths
518
        widths := make([]int, len(headers))
81✔
519
        for i, h := range headers {
251✔
520
                widths[i] = len(h)
170✔
521
        }
170✔
522
        for _, row := range rows {
206✔
523
                for i, cell := range row {
385✔
524
                        if i < len(widths) && len(cell) > widths[i] {
378✔
525
                                widths[i] = len(cell)
118✔
526
                        }
118✔
527
                }
528
        }
529

530
        // Cap column widths at 50 chars for readability
531
        for i := range widths {
251✔
532
                if widths[i] > 50 {
172✔
533
                        widths[i] = 50
2✔
534
                }
2✔
535
        }
536

537
        // Print top border
538
        printBoxLine(widths, "┌", "┬", "┐")
81✔
539

81✔
540
        // Print header row
81✔
541
        fmt.Print("│")
81✔
542
        for i, h := range headers {
251✔
543
                fmt.Printf(" %-*s │", widths[i], truncate(h, widths[i]))
170✔
544
        }
170✔
545
        fmt.Println()
81✔
546

81✔
547
        // Print header separator
81✔
548
        printBoxLine(widths, "├", "┼", "┤")
81✔
549

81✔
550
        // Print data rows
81✔
551
        for _, row := range rows {
206✔
552
                fmt.Print("│")
125✔
553
                for i := 0; i < len(headers); i++ {
385✔
554
                        cell := ""
260✔
555
                        if i < len(row) {
520✔
556
                                cell = row[i]
260✔
557
                        }
260✔
558
                        fmt.Printf(" %-*s │", widths[i], truncate(cell, widths[i]))
260✔
559
                }
560
                fmt.Println()
125✔
561
        }
562

563
        // Print bottom border
564
        printBoxLine(widths, "└", "┴", "┘")
81✔
565
}
566

567
func printBoxLine(widths []int, left, mid, right string) {
243✔
568
        fmt.Print(left)
243✔
569
        for i, w := range widths {
753✔
570
                fmt.Print(strings.Repeat("─", w+2))
510✔
571
                if i < len(widths)-1 {
777✔
572
                        fmt.Print(mid)
267✔
573
                }
267✔
574
        }
575
        fmt.Println(right)
243✔
576
}
577

578
func truncate(s string, maxLen int) string {
430✔
579
        if len(s) <= maxLen {
858✔
580
                return s
428✔
581
        }
428✔
582
        if maxLen <= 3 {
2✔
NEW
583
                return s[:maxLen]
×
NEW
584
        }
×
585
        return s[:maxLen-3] + "..."
2✔
586
}
587

588
func parseAggregateFunc(funcExpr *sqlparser.FuncExpr) *aggregator {
15✔
589
        funcName := strings.ToLower(funcExpr.Name.String())
15✔
590

15✔
591
        var typ aggType
15✔
592
        switch funcName {
15✔
593
        case "count":
4✔
594
                typ = aggCount
4✔
595
        case "sum":
4✔
596
                typ = aggSum
4✔
597
        case "avg":
3✔
598
                typ = aggAvg
3✔
599
        case "min":
2✔
600
                typ = aggMin
2✔
601
        case "max":
2✔
602
                typ = aggMax
2✔
NEW
603
        default:
×
NEW
604
                return nil
×
605
        }
606

607
        agg := &aggregator{typ: typ}
15✔
608

15✔
609
        // Extract field argument for sum/avg/min/max
15✔
610
        if len(funcExpr.Exprs) > 0 {
26✔
611
                if aliased, ok := funcExpr.Exprs[0].(*sqlparser.AliasedExpr); ok {
22✔
612
                        if col, ok := aliased.Expr.(*sqlparser.ColName); ok {
22✔
613
                                qualifier := strings.ToLower(col.Qualifier.Name.String())
11✔
614
                                fieldName := col.Name.String()
11✔
615

11✔
616
                                if qualifier == "v" {
21✔
617
                                        agg.field = fieldName
10✔
618
                                } else if qualifier != "" && qualifier != "kv" {
12✔
619
                                        agg.field = qualifier + "." + fieldName
1✔
620
                                }
1✔
621
                        }
622
                }
623
        }
624

625
        // Build alias for display
626
        if agg.field != "" {
26✔
627
                agg.alias = fmt.Sprintf("%s(v.%s)", funcName, agg.field)
11✔
628
        } else {
15✔
629
                agg.alias = fmt.Sprintf("%s()", funcName)
4✔
630
        }
4✔
631

632
        return agg
15✔
633
}
634

635
func printAggregateResults(aggs []*aggregator) {
9✔
636
        // Print header
9✔
637
        headers := make([]string, len(aggs))
9✔
638
        for i, agg := range aggs {
24✔
639
                headers[i] = agg.alias
15✔
640
        }
15✔
641
        fmt.Println(strings.Join(headers, " | "))
9✔
642

9✔
643
        // Print separator
9✔
644
        seps := make([]string, len(aggs))
9✔
645
        for i, agg := range aggs {
24✔
646
                seps[i] = strings.Repeat("-", len(agg.alias))
15✔
647
        }
15✔
648
        fmt.Println(strings.Join(seps, "-+-"))
9✔
649

9✔
650
        // Print values
9✔
651
        values := make([]string, len(aggs))
9✔
652
        for i, agg := range aggs {
24✔
653
                result := agg.result()
15✔
654
                // Pad to match header width
15✔
655
                if len(result) < len(agg.alias) {
30✔
656
                        result = strings.Repeat(" ", len(agg.alias)-len(result)) + result
15✔
657
                }
15✔
658
                values[i] = result
15✔
659
        }
660
        fmt.Println(strings.Join(values, " | "))
9✔
661
}
662

663
// extractRowFields extracts field values as strings for tabular display
664
func extractRowFields(key []byte, val tinykvs.Value, fields []string) []string {
126✔
665
        keyStr := formatKey(key)
126✔
666

126✔
667
        if len(fields) == 0 {
225✔
668
                // SELECT * - return key and full value
99✔
669
                return []string{keyStr, formatValue(val)}
99✔
670
        }
99✔
671

672
        // Extract specific fields
673
        row := []string{keyStr}
27✔
674

27✔
675
        var record map[string]any
27✔
676
        switch val.Type {
27✔
677
        case tinykvs.ValueTypeRecord:
2✔
678
                record = val.Record
2✔
679
        case tinykvs.ValueTypeMsgpack:
23✔
680
                if err := msgpack.Unmarshal(val.Bytes, &record); err != nil {
23✔
NEW
681
                        // Can't decode, return NULLs for all fields
×
NEW
682
                        for range fields {
×
NEW
683
                                row = append(row, "NULL")
×
NEW
684
                        }
×
NEW
685
                        return row
×
686
                }
687
        default:
2✔
688
                // Non-record type, return NULLs for all fields
2✔
689
                for range fields {
4✔
690
                        row = append(row, "NULL")
2✔
691
                }
2✔
692
                return row
2✔
693
        }
694

695
        for _, field := range fields {
60✔
696
                if v, ok := extractNestedField(record, field); ok {
67✔
697
                        row = append(row, fmt.Sprintf("%v", v))
32✔
698
                } else {
35✔
699
                        row = append(row, "NULL")
3✔
700
                }
3✔
701
        }
702
        return row
25✔
703
}
704

705
func formatValue(val tinykvs.Value) string {
99✔
706
        switch val.Type {
99✔
NEW
707
        case tinykvs.ValueTypeInt64:
×
NEW
708
                return fmt.Sprintf("%d", val.Int64)
×
709
        case tinykvs.ValueTypeFloat64:
1✔
710
                return fmt.Sprintf("%f", val.Float64)
1✔
711
        case tinykvs.ValueTypeBool:
1✔
712
                return fmt.Sprintf("%t", val.Bool)
1✔
713
        case tinykvs.ValueTypeString, tinykvs.ValueTypeBytes:
83✔
714
                if len(val.Bytes) > 100 {
84✔
715
                        return string(val.Bytes[:100]) + "..."
1✔
716
                }
1✔
717
                return string(val.Bytes)
82✔
718
        case tinykvs.ValueTypeRecord:
1✔
719
                if val.Record == nil {
2✔
720
                        return "{}"
1✔
721
                }
1✔
NEW
722
                jsonBytes, _ := json.Marshal(val.Record)
×
NEW
723
                return string(jsonBytes)
×
724
        case tinykvs.ValueTypeMsgpack:
12✔
725
                var record map[string]any
12✔
726
                if err := msgpack.Unmarshal(val.Bytes, &record); err != nil {
12✔
NEW
727
                        return "(msgpack)"
×
NEW
728
                }
×
729
                jsonBytes, _ := json.Marshal(record)
12✔
730
                return string(jsonBytes)
12✔
731
        default:
1✔
732
                return "(unknown)"
1✔
733
        }
734
}
735

736
func (s *Shell) parseWhere(expr sqlparser.Expr, keyEquals, keyPrefix, keyStart, keyEnd *string, valueFilters *[]*valueFilter) {
101✔
737
        switch e := expr.(type) {
101✔
738
        case *sqlparser.ComparisonExpr:
93✔
739
                if col, ok := e.Left.(*sqlparser.ColName); ok {
186✔
740
                        qualifier := strings.ToLower(col.Qualifier.Name.String())
93✔
741
                        colName := strings.ToLower(col.Name.String())
93✔
742

93✔
743
                        // Check if this is a key filter (k = ..., k LIKE ..., etc.)
93✔
744
                        isKeyFilter := (colName == "k" && qualifier == "") ||
93✔
745
                                (colName == "k" && qualifier == "kv")
93✔
746

93✔
747
                        if isKeyFilter {
186✔
748
                                // Key filter
93✔
749
                                val, isHexPrefix := extractValueForLike(e.Right, e.Operator)
93✔
750
                                switch e.Operator {
93✔
751
                                case "=":
70✔
752
                                        *keyEquals = val
70✔
753
                                case "like":
16✔
754
                                        if isHexPrefix {
16✔
NEW
755
                                                *keyPrefix = val
×
756
                                        } else if strings.HasSuffix(val, "%") && !strings.Contains(val[:len(val)-1], "%") {
31✔
757
                                                *keyPrefix = val[:len(val)-1]
15✔
758
                                        } else {
16✔
759
                                                fmt.Println("Warning: LIKE only supports prefix matching (e.g., 'prefix%' or x'14%')")
1✔
760
                                        }
1✔
761
                                case ">=":
4✔
762
                                        *keyStart = val
4✔
763
                                case "<=":
3✔
764
                                        *keyEnd = val
3✔
765
                                }
NEW
766
                        } else {
×
NEW
767
                                // Value field filter: v.field, v.a.b, or just field (assume v.)
×
NEW
768
                                var fieldName string
×
NEW
769
                                if qualifier == "v" {
×
NEW
770
                                        // v.ttl → field is "ttl"
×
NEW
771
                                        fieldName = colName
×
NEW
772
                                } else if qualifier != "" && qualifier != "kv" {
×
NEW
773
                                        // Nested: parsed as ttl.sub → field is "ttl.sub"
×
NEW
774
                                        // Or v.address.city parsed as address.city
×
NEW
775
                                        fieldName = qualifier + "." + colName
×
NEW
776
                                } else {
×
NEW
777
                                        // No qualifier, assume it's a value field
×
NEW
778
                                        // e.g., just "ttl" means v.ttl
×
NEW
779
                                        fieldName = colName
×
NEW
780
                                }
×
781

NEW
782
                                val := extractValue(e.Right)
×
NEW
783
                                *valueFilters = append(*valueFilters, &valueFilter{
×
NEW
784
                                        field:    fieldName,
×
NEW
785
                                        operator: e.Operator,
×
NEW
786
                                        value:    val,
×
NEW
787
                                })
×
788
                        }
789
                }
790
        case *sqlparser.RangeCond:
6✔
791
                // BETWEEN
6✔
792
                if col, ok := e.Left.(*sqlparser.ColName); ok {
12✔
793
                        if strings.ToLower(col.Name.String()) == "k" {
12✔
794
                                *keyStart = extractValue(e.From)
6✔
795
                                *keyEnd = extractValue(e.To)
6✔
796
                        }
6✔
797
                }
798
        case *sqlparser.AndExpr:
2✔
799
                s.parseWhere(e.Left, keyEquals, keyPrefix, keyStart, keyEnd, valueFilters)
2✔
800
                s.parseWhere(e.Right, keyEquals, keyPrefix, keyStart, keyEnd, valueFilters)
2✔
801
        }
802
}
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