Skip to content

Commit 88599d5

Browse files
ajitpratap0Ajit Pratap Singhclaude
authored
feat(p3): CLI watch registration, pool stats API, JSON function parsing (#458 #459 #460)
Part A (#458): Register watch command in CLI root - Added cmd/gosqlx/cmd/watch_cmd.go with cobra command wrapping existing FileWatcher - Supports --mode (validate|format), --debounce, --clear, --watch-verbose flags - gosqlx watch now appears in --help output Part B (#460): Pool utilization stats API - Added pkg/metrics/pool_stats.go: PoolStat struct, RecordNamedPoolGet/Put, GetPoolStats, ResetPoolStats - Instrumented tokenizer, parser, and AST pools with RecordNamedPoolGet/Put calls - Added cmd/gosqlx/cmd/stats.go: gosqlx stats command with human-readable table and --json flag - Added pkg/metrics/pool_stats_test.go with 8 race-safe tests including concurrent safety Part C (#459): JSON function parsing tests - Added pkg/sql/parser/json_functions_test.go: 12 tests covering JSON_OBJECT, JSON_BUILD_ARRAY, JSON_EXTRACT, JSON_AGG, JSON_BUILD_OBJECT, JSON_VALUE, JSON_CONTAINS, JSON_SET, JSONB_AGG, TO_JSON, JSON_ARRAYAGG, JSON_LENGTH, and arrow operators (all PASS) - Parser already handles JSON functions as generic FunctionCall nodes Co-authored-by: Ajit Pratap Singh <ajitpratapsingh@Ajits-Mac-mini-2655.local> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 42b44aa commit 88599d5

File tree

8 files changed

+615
-0
lines changed

8 files changed

+615
-0
lines changed

cmd/gosqlx/cmd/stats.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright 2026 GoSQLX Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package cmd
16+
17+
import (
18+
"encoding/json"
19+
"fmt"
20+
"sort"
21+
22+
"github.com/spf13/cobra"
23+
24+
"github.com/ajitpratap0/GoSQLX/pkg/metrics"
25+
)
26+
27+
// statsCmd shows current object pool utilization counters.
28+
var statsCmd = &cobra.Command{
29+
Use: "stats",
30+
Short: "Show pool utilization statistics",
31+
Long: `Display current object pool utilization counters.
32+
33+
Shows gets (pool retrievals), puts (pool returns), and active (currently borrowed)
34+
counts for each named pool: tokenizer, parser, and ast.
35+
36+
Examples:
37+
gosqlx stats
38+
gosqlx stats --json`,
39+
RunE: func(cmd *cobra.Command, args []string) error {
40+
jsonOutput, _ := cmd.Flags().GetBool("json")
41+
stats := metrics.GetPoolStats()
42+
43+
if jsonOutput {
44+
b, err := json.MarshalIndent(stats, "", " ")
45+
if err != nil {
46+
return err
47+
}
48+
fmt.Fprintln(cmd.OutOrStdout(), string(b))
49+
return nil
50+
}
51+
52+
// Human-readable table
53+
out := cmd.OutOrStdout()
54+
fmt.Fprintf(out, "%-20s %10s %10s %10s\n", "POOL", "GETS", "PUTS", "ACTIVE")
55+
fmt.Fprintf(out, "%-20s %10s %10s %10s\n", "----", "----", "----", "------")
56+
57+
names := make([]string, 0, len(stats))
58+
for k := range stats {
59+
names = append(names, k)
60+
}
61+
sort.Strings(names)
62+
63+
for _, name := range names {
64+
s := stats[name]
65+
fmt.Fprintf(out, "%-20s %10d %10d %10d\n", name, s.Gets, s.Puts, s.Active())
66+
}
67+
return nil
68+
},
69+
}
70+
71+
func init() {
72+
statsCmd.Flags().Bool("json", false, "Output as JSON")
73+
rootCmd.AddCommand(statsCmd)
74+
}

cmd/gosqlx/cmd/watch_cmd.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright 2026 GoSQLX Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package cmd
16+
17+
import (
18+
"github.com/spf13/cobra"
19+
)
20+
21+
// watchCmd is the cobra command that registers the watch subcommand.
22+
// The FileWatcher implementation lives in watch.go.
23+
var watchCmd = &cobra.Command{
24+
Use: "watch [file|dir...]",
25+
Short: "Watch SQL files and re-validate or re-format on change",
26+
Long: `Watch SQL files or directories and automatically re-process them when changes are detected.
27+
28+
Supports two modes:
29+
• validate (default): Re-run SQL validation on every file change
30+
• format: Re-format SQL files in-place on every file change
31+
32+
Examples:
33+
gosqlx watch *.sql
34+
gosqlx watch --mode format queries/
35+
gosqlx watch --debounce 500 schema.sql migrations/`,
36+
RunE: func(cmd *cobra.Command, args []string) error {
37+
if len(args) == 0 {
38+
return cmd.Help()
39+
}
40+
41+
modeStr, _ := cmd.Flags().GetString("mode")
42+
debounce, _ := cmd.Flags().GetInt("debounce")
43+
clear, _ := cmd.Flags().GetBool("clear")
44+
watchVerbose, _ := cmd.Flags().GetBool("watch-verbose")
45+
46+
mode := WatchModeValidate
47+
if modeStr == "format" {
48+
mode = WatchModeFormat
49+
}
50+
51+
opts := WatchOptions{
52+
Mode: mode,
53+
DebounceMs: debounce,
54+
ClearScreen: clear,
55+
Verbose: watchVerbose,
56+
Out: cmd.OutOrStdout(),
57+
Err: cmd.ErrOrStderr(),
58+
}
59+
60+
fw, err := NewFileWatcher(opts)
61+
if err != nil {
62+
return err
63+
}
64+
return fw.Watch(args)
65+
},
66+
}
67+
68+
func init() {
69+
watchCmd.Flags().String("mode", "validate", "watch mode: validate or format")
70+
watchCmd.Flags().Int("debounce", 300, "debounce delay in milliseconds")
71+
watchCmd.Flags().Bool("clear", false, "clear screen before each re-run")
72+
watchCmd.Flags().Bool("watch-verbose", false, "verbose output from watcher")
73+
rootCmd.AddCommand(watchCmd)
74+
}

pkg/metrics/pool_stats.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright 2026 GoSQLX Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package metrics
16+
17+
import (
18+
"sync"
19+
"sync/atomic"
20+
)
21+
22+
// PoolStat holds utilization counters for a single named object pool.
23+
// Gets counts total Get() calls (items retrieved), Puts counts total Put() calls
24+
// (items returned). Active() returns an estimate of currently borrowed items.
25+
type PoolStat struct {
26+
Gets int64 `json:"gets"` // total Get() calls
27+
Puts int64 `json:"puts"` // total Put() calls
28+
}
29+
30+
// Active returns an estimate of currently borrowed items (Gets - Puts).
31+
// May be negative during warm-up before items are returned to the pool.
32+
func (s PoolStat) Active() int64 {
33+
return s.Gets - s.Puts
34+
}
35+
36+
// poolCounter is the internal per-pool atomic counter pair.
37+
type poolCounter struct {
38+
gets int64
39+
puts int64
40+
}
41+
42+
var (
43+
poolStatsMu sync.RWMutex
44+
poolCounters = map[string]*poolCounter{}
45+
)
46+
47+
// standardPoolNames are always pre-populated in GetPoolStats output.
48+
var standardPoolNames = []string{"tokenizer", "parser", "ast"}
49+
50+
func getOrCreatePoolCounter(name string) *poolCounter {
51+
poolStatsMu.RLock()
52+
c, ok := poolCounters[name]
53+
poolStatsMu.RUnlock()
54+
if ok {
55+
return c
56+
}
57+
poolStatsMu.Lock()
58+
defer poolStatsMu.Unlock()
59+
if c, ok = poolCounters[name]; ok {
60+
return c
61+
}
62+
c = &poolCounter{}
63+
poolCounters[name] = c
64+
return c
65+
}
66+
67+
// RecordNamedPoolGet records a Get() call for the named pool and returns the new total.
68+
// Call this immediately after retrieving an object from a sync.Pool.
69+
// This is safe for concurrent use and uses atomic operations.
70+
func RecordNamedPoolGet(name string) int64 {
71+
c := getOrCreatePoolCounter(name)
72+
return atomic.AddInt64(&c.gets, 1)
73+
}
74+
75+
// RecordNamedPoolPut records a Put() call for the named pool and returns the new total.
76+
// Call this immediately before or after returning an object to a sync.Pool.
77+
// This is safe for concurrent use and uses atomic operations.
78+
func RecordNamedPoolPut(name string) int64 {
79+
c := getOrCreatePoolCounter(name)
80+
return atomic.AddInt64(&c.puts, 1)
81+
}
82+
83+
// GetPoolStats returns a snapshot of all named pool counters.
84+
// The map is keyed by pool name. Standard pool names ("tokenizer", "parser", "ast")
85+
// are always present with zero values even if no operations have been recorded.
86+
// This function is safe for concurrent use.
87+
func GetPoolStats() map[string]PoolStat {
88+
// Ensure standard pools are always present
89+
for _, name := range standardPoolNames {
90+
getOrCreatePoolCounter(name)
91+
}
92+
93+
poolStatsMu.RLock()
94+
defer poolStatsMu.RUnlock()
95+
result := make(map[string]PoolStat, len(poolCounters))
96+
for name, c := range poolCounters {
97+
result[name] = PoolStat{
98+
Gets: atomic.LoadInt64(&c.gets),
99+
Puts: atomic.LoadInt64(&c.puts),
100+
}
101+
}
102+
return result
103+
}
104+
105+
// ResetPoolStats zeroes all named pool counters. Primarily intended for testing.
106+
// This is safe for concurrent use.
107+
func ResetPoolStats() {
108+
poolStatsMu.Lock()
109+
defer poolStatsMu.Unlock()
110+
poolCounters = map[string]*poolCounter{}
111+
}

pkg/metrics/pool_stats_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright 2026 GoSQLX Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package metrics_test
16+
17+
import (
18+
"testing"
19+
20+
"github.com/ajitpratap0/GoSQLX/pkg/metrics"
21+
)
22+
23+
func TestPoolStats_ReturnsNonNilResult(t *testing.T) {
24+
stats := metrics.GetPoolStats()
25+
if stats == nil {
26+
t.Fatal("GetPoolStats returned nil")
27+
}
28+
}
29+
30+
func TestPoolStats_HasTokenizerPool(t *testing.T) {
31+
stats := metrics.GetPoolStats()
32+
if _, ok := stats["tokenizer"]; !ok {
33+
t.Error("expected 'tokenizer' key in pool stats")
34+
}
35+
}
36+
37+
func TestPoolStats_HasParserPool(t *testing.T) {
38+
stats := metrics.GetPoolStats()
39+
if _, ok := stats["parser"]; !ok {
40+
t.Error("expected 'parser' key in pool stats")
41+
}
42+
}
43+
44+
func TestPoolStats_HasASTPool(t *testing.T) {
45+
stats := metrics.GetPoolStats()
46+
if _, ok := stats["ast"]; !ok {
47+
t.Error("expected 'ast' key in pool stats")
48+
}
49+
}
50+
51+
func TestPoolStats_RecordGet(t *testing.T) {
52+
metrics.ResetPoolStats()
53+
54+
_ = metrics.RecordNamedPoolGet("tokenizer")
55+
_ = metrics.RecordNamedPoolPut("tokenizer")
56+
57+
stats := metrics.GetPoolStats()
58+
ts, ok := stats["tokenizer"]
59+
if !ok {
60+
t.Fatal("missing tokenizer key after reset+record")
61+
}
62+
if ts.Gets < 1 {
63+
t.Errorf("expected Gets >= 1 after RecordNamedPoolGet, got %d", ts.Gets)
64+
}
65+
if ts.Puts < 1 {
66+
t.Errorf("expected Puts >= 1 after RecordNamedPoolPut, got %d", ts.Puts)
67+
}
68+
}
69+
70+
func TestPoolStats_ActiveCalculation(t *testing.T) {
71+
metrics.ResetPoolStats()
72+
73+
metrics.RecordNamedPoolGet("parser")
74+
metrics.RecordNamedPoolGet("parser")
75+
metrics.RecordNamedPoolPut("parser")
76+
77+
stats := metrics.GetPoolStats()
78+
ps := stats["parser"]
79+
if ps.Gets != 2 {
80+
t.Errorf("expected Gets=2, got %d", ps.Gets)
81+
}
82+
if ps.Puts != 1 {
83+
t.Errorf("expected Puts=1, got %d", ps.Puts)
84+
}
85+
if ps.Active() != 1 {
86+
t.Errorf("expected Active()=1, got %d", ps.Active())
87+
}
88+
}
89+
90+
func TestPoolStats_ResetClearsCounters(t *testing.T) {
91+
metrics.RecordNamedPoolGet("ast")
92+
metrics.ResetPoolStats()
93+
94+
stats := metrics.GetPoolStats()
95+
// After reset, standard pools are recreated with zero values
96+
as := stats["ast"]
97+
if as.Gets != 0 {
98+
t.Errorf("expected Gets=0 after reset, got %d", as.Gets)
99+
}
100+
}
101+
102+
func TestPoolStats_ConcurrentSafe(t *testing.T) {
103+
metrics.ResetPoolStats()
104+
105+
done := make(chan struct{})
106+
for i := 0; i < 100; i++ {
107+
go func() {
108+
metrics.RecordNamedPoolGet("tokenizer")
109+
metrics.RecordNamedPoolPut("tokenizer")
110+
done <- struct{}{}
111+
}()
112+
}
113+
for i := 0; i < 100; i++ {
114+
<-done
115+
}
116+
117+
stats := metrics.GetPoolStats()
118+
ts := stats["tokenizer"]
119+
if ts.Gets != 100 {
120+
t.Errorf("expected Gets=100, got %d", ts.Gets)
121+
}
122+
if ts.Puts != 100 {
123+
t.Errorf("expected Puts=100, got %d", ts.Puts)
124+
}
125+
}

0 commit comments

Comments
 (0)