Prechádzať zdrojové kódy

repl improvements: readline, autocomplete, history, version, status

Danilo Fragoso 3 mesiacov pred
rodič
commit
dbcff0bb39
10 zmenil súbory, kde vykonal 314 pridanie a 12 odobranie
  1. 7 2
      Makefile
  2. BIN
      bin/pizzasql
  3. 1 0
      go.mod
  4. 5 0
      go.sum
  5. 198 6
      main.go
  6. 17 1
      pkg/analyzer/types.go
  7. 4 1
      pkg/executor/executor.go
  8. 2 0
      pkg/executor/executor_test.go
  9. 3 2
      pkg/httpserver/handler.go
  10. 77 0
      pkg/version/version.go

+ 7 - 2
Makefile

@@ -2,9 +2,14 @@
 
 PREFIX ?= /usr/local
 
+GIT_VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
+GIT_COMMIT := $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo unknown)
+GIT_DATE := $(shell git show -s --format=%cI HEAD 2>/dev/null || echo unknown)
+LDFLAGS := -X github.com/danfragoso/pizzasql-next/pkg/version.Version=$(GIT_VERSION) -X github.com/danfragoso/pizzasql-next/pkg/version.Commit=$(GIT_COMMIT) -X github.com/danfragoso/pizzasql-next/pkg/version.Date=$(GIT_DATE)
+
 # Build the project
 build:
-	go build -o ./bin/pizzasql ./main.go
+	go build -ldflags "$(LDFLAGS)" -o ./bin/pizzasql ./main.go
 
 # Install pizzasql to PREFIX/bin (default: /usr/local/bin)
 install: build
@@ -18,7 +23,7 @@ uninstall:
 	@echo "Removed $(DESTDIR)$(PREFIX)/bin/pizzasql"
 
 build-linux-amd64:
-	GOOS=linux GOARCH=amd64 go build -o ./bin/pizzasql-linux-amd64 ./main.go
+	GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o ./bin/pizzasql-linux-amd64 ./main.go
 
 # Run all tests
 test:

BIN
bin/pizzasql


+ 1 - 0
go.mod

@@ -3,6 +3,7 @@ module github.com/danfragoso/pizzasql-next
 go 1.25.0
 
 require (
+	github.com/chzyer/readline v1.5.1 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/goccy/go-json v0.10.6 // indirect
 	github.com/google/uuid v1.6.0 // indirect

+ 5 - 0
go.sum

@@ -4,6 +4,10 @@ github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoO
 github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
 github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
 github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
+github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
+github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
+github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
+github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
 github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
 github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -43,6 +47,7 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
 github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
 golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
 golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=

+ 198 - 6
main.go

@@ -5,13 +5,17 @@ import (
 	"context"
 	"flag"
 	"fmt"
+	"io"
 	"net/http"
 	"os"
 	"os/signal"
+	"path/filepath"
 	"strings"
 	"syscall"
 	"time"
 
+	"github.com/chzyer/readline"
+	"github.com/danfragoso/pizzasql-next/pkg/analyzer"
 	"github.com/danfragoso/pizzasql-next/pkg/csvexport"
 	"github.com/danfragoso/pizzasql-next/pkg/csvimport"
 	"github.com/danfragoso/pizzasql-next/pkg/executor"
@@ -25,6 +29,7 @@ import (
 	"github.com/danfragoso/pizzasql-next/pkg/sqlimport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqliteimport"
 	"github.com/danfragoso/pizzasql-next/pkg/storage"
+	"github.com/danfragoso/pizzasql-next/pkg/version"
 )
 
 var (
@@ -209,6 +214,7 @@ func executePipe() {
 
 func runREPL() {
 	fmt.Println("PizzaSQL - SQL-92 compatible database")
+	fmt.Printf("Build: %s\n", version.String())
 	fmt.Println("Type 'help' for usage, 'quit' to exit")
 	fmt.Println()
 
@@ -231,20 +237,46 @@ func runREPL() {
 		fmt.Printf("Connected to PizzaKV at %s (database: %s)\n\n", *kvAddr, *database)
 	}
 
-	reader := bufio.NewReader(os.Stdin)
+	historyFile := replHistoryFile()
+	rl, err := readline.NewEx(&readline.Config{
+		Prompt:          "pizzasql> ",
+		HistoryFile:     historyFile,
+		InterruptPrompt: "^C",
+		EOFPrompt:       "exit",
+		AutoComplete:    newREPLCompleter(schema),
+	})
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Failed to initialize interactive input: %v\n", err)
+		return
+	}
+	defer rl.Close()
+
 	var sqlBuffer strings.Builder
 
 	for {
 		if sqlBuffer.Len() == 0 {
-			fmt.Print("pizzasql> ")
+			rl.SetPrompt("pizzasql> ")
 		} else {
-			fmt.Print("       -> ")
+			rl.SetPrompt("       -> ")
 		}
 
-		line, err := reader.ReadString('\n')
+		line, err := rl.Readline()
 		if err != nil {
-			fmt.Println()
-			break
+			if err == readline.ErrInterrupt {
+				if sqlBuffer.Len() > 0 {
+					sqlBuffer.Reset()
+					fmt.Println("Buffer cleared")
+					continue
+				}
+				fmt.Println("^C")
+				continue
+			}
+			if err == io.EOF {
+				fmt.Println()
+				break
+			}
+			fmt.Fprintf(os.Stderr, "Input error: %v\n", err)
+			continue
 		}
 
 		line = strings.TrimSpace(line)
@@ -271,6 +303,12 @@ func runREPL() {
 			sqlBuffer.Reset()
 			fmt.Println("Buffer cleared")
 			continue
+		case "status", "\\s":
+			printStatus(exec != nil)
+			continue
+		case "functions", "\\df":
+			printFunctions()
+			continue
 		}
 
 		// Skip empty lines and comments
@@ -307,6 +345,121 @@ func runREPL() {
 	}
 }
 
+type replCompleter struct {
+	getTables func() []string
+}
+
+func newREPLCompleter(schema *storage.SchemaManager) readline.AutoCompleter {
+	return &replCompleter{
+		getTables: func() []string {
+			if schema == nil {
+				return nil
+			}
+			tables, err := schema.ListTables()
+			if err != nil {
+				return nil
+			}
+			return tables
+		},
+	}
+}
+
+func (c *replCompleter) Do(line []rune, pos int) ([][]rune, int) {
+	if pos > len(line) {
+		pos = len(line)
+	}
+	fragment := string(line[:pos])
+	start := pos
+	for start > 0 {
+		r := line[start-1]
+		if !(r == '_' || r == '\\' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
+			break
+		}
+		start--
+	}
+	prefix := fragment[start:pos]
+	prefixUpper := strings.ToUpper(prefix)
+
+	candidates := append(replCommands(), sqlKeywords()...)
+	candidates = append(candidates, c.getTables()...)
+
+	seen := make(map[string]struct{}, len(candidates))
+	var out [][]rune
+	for _, cand := range candidates {
+		cand = strings.TrimSpace(cand)
+		if cand == "" {
+			continue
+		}
+		upper := strings.ToUpper(cand)
+		if _, ok := seen[upper]; ok {
+			continue
+		}
+		seen[upper] = struct{}{}
+		if prefixUpper == "" || strings.HasPrefix(upper, prefixUpper) {
+			suffix := cand
+			if len(prefix) > 0 && len(cand) >= len(prefix) && strings.EqualFold(cand[:len(prefix)], prefix) {
+				suffix = cand[len(prefix):]
+			}
+			suffix = matchSuffixCase(prefix, suffix)
+			out = append(out, []rune(suffix))
+		}
+	}
+
+	return out, len(prefix)
+}
+
+func replHistoryFile() string {
+	home, err := os.UserHomeDir()
+	if err != nil || home == "" {
+		return ".pizzasql_history"
+	}
+	return filepath.Join(home, ".pizzasql_history")
+}
+
+func replCommands() []string {
+	return []string{"help", "quit", "exit", "tables", "clear", "status", "functions", "\\h", "\\q", "\\dt", "\\c", "\\s", "\\df"}
+}
+
+func sqlKeywords() []string {
+	return []string{
+		"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE",
+		"CREATE", "TABLE", "DROP", "ALTER", "INDEX", "VIEW", "JOIN", "LEFT", "RIGHT", "INNER",
+		"ON", "GROUP", "BY", "ORDER", "LIMIT", "OFFSET", "HAVING", "DISTINCT", "AS", "AND", "OR",
+		"NOT", "NULL", "TRUE", "FALSE", "PRAGMA", "BEGIN", "COMMIT", "ROLLBACK", "PIZZASQL_VERSION",
+	}
+}
+
+func matchSuffixCase(prefix, suffix string) string {
+	if prefix == "" || suffix == "" {
+		return suffix
+	}
+	hasLetter := false
+	allUpper := true
+	allLower := true
+	for _, r := range prefix {
+		if r >= 'A' && r <= 'Z' {
+			hasLetter = true
+			allLower = false
+			continue
+		}
+		if r >= 'a' && r <= 'z' {
+			hasLetter = true
+			allUpper = false
+			continue
+		}
+	}
+	if !hasLetter {
+		return suffix
+	}
+	if allUpper {
+		return strings.ToUpper(suffix)
+	}
+	if allLower {
+		return strings.ToLower(suffix)
+	}
+	return suffix
+}
+
 func executeSQL(exec *executor.Executor, sql string) (*executor.Result, error) {
 	l := lexer.New(sql)
 	p := parser.New(l)
@@ -445,6 +598,13 @@ func evalExprSimple(expr parser.Expr) (interface{}, error) {
 		return val, nil
 	case *parser.ParenExpr:
 		return evalExprSimple(e.Expr)
+	case *parser.FunctionCall:
+		switch strings.ToUpper(e.Name) {
+		case "PIZZASQL_VERSION", "SQLITE_VERSION":
+			return version.String(), nil
+		default:
+			return nil, fmt.Errorf("unsupported function in expression mode: %s", e.Name)
+		}
 	}
 	return nil, fmt.Errorf("unsupported expression type: %T", expr)
 }
@@ -528,6 +688,8 @@ func printHelp() {
 	fmt.Println("  quit, \\q     Exit the program")
 	fmt.Println("  tables, \\dt  List all tables")
 	fmt.Println("  clear, \\c    Clear the input buffer")
+	fmt.Println("  status, \\s   Show build and connection status")
+	fmt.Println("  functions, \\df List built-in SQL functions")
 	fmt.Println()
 	fmt.Println("SQL Statements (end with semicolon):")
 	fmt.Println("  SELECT ... FROM ... WHERE ...")
@@ -572,6 +734,36 @@ func listTables(schema *storage.SchemaManager) {
 		fmt.Printf("  %s\n", t)
 	}
 }
+
+func printStatus(connected bool) {
+	fmt.Printf("version: %s\n", version.String())
+	if connected {
+		fmt.Println("storage: connected")
+		return
+	}
+	fmt.Println("storage: expression-only mode")
+}
+
+func printFunctions() {
+	fns := analyzer.BuiltinFunctions()
+	fmt.Println("Built-in SQL functions:")
+	for _, fn := range fns {
+		kind := "scalar"
+		if fn.IsAggregate {
+			kind = "aggregate"
+		}
+		if fn.MaxArgs < 0 {
+			fmt.Printf("  %-18s %s (args: %d+)\n", fn.Name, kind, fn.MinArgs)
+			continue
+		}
+		if fn.MinArgs == fn.MaxArgs {
+			fmt.Printf("  %-18s %s (args: %d)\n", fn.Name, kind, fn.MinArgs)
+			continue
+		}
+		fmt.Printf("  %-18s %s (args: %d..%d)\n", fn.Name, kind, fn.MinArgs, fn.MaxArgs)
+	}
+}
+
 func runExport() {
 	// Connect to PizzaKV
 	pool, err := storage.NewKVPool(*kvAddr, *poolSize, *timeout)

+ 17 - 1
pkg/analyzer/types.go

@@ -1,6 +1,9 @@
 package analyzer
 
-import "strings"
+import (
+	"sort"
+	"strings"
+)
 
 // Type represents a SQL type with SQLite affinity rules.
 type Type int
@@ -229,6 +232,7 @@ var builtinFunctions = map[string]FunctionSignature{
 
 	// SQLite specific
 	"SQLITE_VERSION": {Name: "SQLITE_VERSION", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeText, IsAggregate: false},
+	"PIZZASQL_VERSION": {Name: "PIZZASQL_VERSION", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeText, IsAggregate: false},
 	"LAST_INSERT_ROWID": {Name: "LAST_INSERT_ROWID", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeInteger, IsAggregate: false},
 	"CHANGES": {Name: "CHANGES", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeInteger, IsAggregate: false},
 	"TOTAL_CHANGES": {Name: "TOTAL_CHANGES", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeInteger, IsAggregate: false},
@@ -252,6 +256,18 @@ func IsAggregateFunction(name string) bool {
 	return ok && sig.IsAggregate
 }
 
+// BuiltinFunctions returns all built-in functions sorted by name.
+func BuiltinFunctions() []FunctionSignature {
+	functions := make([]FunctionSignature, 0, len(builtinFunctions))
+	for _, sig := range builtinFunctions {
+		functions = append(functions, sig)
+	}
+	sort.Slice(functions, func(i, j int) bool {
+		return functions[i].Name < functions[j].Name
+	})
+	return functions
+}
+
 // ColumnInfo describes a column in a table.
 type ColumnInfo struct {
 	Name       string

+ 4 - 1
pkg/executor/executor.go

@@ -12,6 +12,7 @@ import (
 	"github.com/danfragoso/pizzasql-next/pkg/lexer"
 	"github.com/danfragoso/pizzasql-next/pkg/parser"
 	"github.com/danfragoso/pizzasql-next/pkg/storage"
+	"github.com/danfragoso/pizzasql-next/pkg/version"
 )
 
 // Executor executes SQL statements.
@@ -3012,7 +3013,7 @@ func (e *Executor) pragmaDatabaseList() (*Result, error) {
 func (e *Executor) pragmaVersion() (*Result, error) {
 	result := NewResult("PRAGMA")
 	result.AddColumn("version")
-	result.AddRow("PizzaSQL 1.0.0")
+	result.AddRow(version.String())
 	return result, nil
 }
 
@@ -3858,6 +3859,8 @@ func (e *Executor) evalFunctionCall(fn *parser.FunctionCall, row storage.Row) (i
 		return evalStrftimeFunc(args)
 	case "TIMEDIFF":
 		return evalTimediffFunc(args)
+	case "PIZZASQL_VERSION", "SQLITE_VERSION":
+		return version.String(), nil
 	}
 
 	return nil, nil

+ 2 - 0
pkg/executor/executor_test.go

@@ -8,6 +8,7 @@ import (
 	"github.com/danfragoso/pizzasql-next/pkg/lexer"
 	"github.com/danfragoso/pizzasql-next/pkg/parser"
 	"github.com/danfragoso/pizzasql-next/pkg/storage"
+	"github.com/danfragoso/pizzasql-next/pkg/version"
 )
 
 func parse(t *testing.T, sql string) parser.Statement {
@@ -596,6 +597,7 @@ func TestEvalSQLiteFunctions(t *testing.T) {
 		// CONCAT
 		{"CONCAT('hello', ' ', 'world')", "hello world", false},
 		{"CONCAT('a', 'b', 'c')", "abc", false},
+		{"PIZZASQL_VERSION()", version.String(), false},
 
 		// MAX/MIN (scalar versions)
 		{"MAX(1, 5, 3)", int64(5), false},

+ 3 - 2
pkg/httpserver/handler.go

@@ -17,6 +17,7 @@ import (
 	"github.com/danfragoso/pizzasql-next/pkg/sqlexport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqlimport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqliteimport"
+	"github.com/danfragoso/pizzasql-next/pkg/version"
 )
 
 // QueryRequest represents a single query request.
@@ -368,7 +369,7 @@ func (s *Server) handleSchemaTable(w http.ResponseWriter, r *http.Request) {
 func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
 	resp := map[string]interface{}{
 		"status":  "ok",
-		"version": "0.1.0",
+		"version": version.String(),
 		"uptime":  time.Since(s.stats.StartTime).String(),
 	}
 
@@ -631,7 +632,7 @@ func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
 
 	fmt.Fprintf(w, "# HELP pizzasql_info PizzaSQL server information\n")
 	fmt.Fprintf(w, "# TYPE pizzasql_info gauge\n")
-	fmt.Fprintf(w, "pizzasql_info{version=\"0.1.0\"} 1\n")
+	fmt.Fprintf(w, "pizzasql_info{version=\"%s\"} 1\n", version.PrometheusLabel())
 }
 
 // handleExport handles GET /export

+ 77 - 0
pkg/version/version.go

@@ -0,0 +1,77 @@
+package version
+
+import (
+	"fmt"
+	"runtime/debug"
+	"strings"
+)
+
+var (
+	Version = "dev"
+	Commit  = "unknown"
+	Date    = "unknown"
+	Dirty   = ""
+)
+
+func init() {
+	info, ok := debug.ReadBuildInfo()
+	if !ok {
+		return
+	}
+
+	for _, setting := range info.Settings {
+		switch setting.Key {
+		case "vcs.revision":
+			if Commit == "unknown" && setting.Value != "" {
+				Commit = shortCommit(setting.Value)
+			}
+		case "vcs.time":
+			if Date == "unknown" && setting.Value != "" {
+				Date = setting.Value
+			}
+		case "vcs.modified":
+			if setting.Value == "true" {
+				Dirty = "-dirty"
+			}
+		}
+	}
+}
+
+func shortCommit(commit string) string {
+	if len(commit) > 12 {
+		return commit[:12]
+	}
+	return commit
+}
+
+func String() string {
+	v := Version
+	if v == "" || v == "dev" {
+		v = Commit
+	}
+	if v == "" || v == "unknown" {
+		v = "dev"
+	}
+	if Dirty != "" && !strings.HasSuffix(v, Dirty) {
+		v += Dirty
+	}
+	if Date == "" || Date == "unknown" {
+		return v
+	}
+	return fmt.Sprintf("%s_%s", v, normalizeDate(Date))
+}
+
+func normalizeDate(date string) string {
+	d := strings.TrimSpace(date)
+	if idx := strings.LastIndexAny(d, "+-"); idx > 0 {
+		d = d[:idx] + "TZ" + d[idx+1:]
+	}
+	d = strings.ReplaceAll(d, "-", "_")
+	d = strings.ReplaceAll(d, ":", "_")
+	d = strings.ReplaceAll(d, " ", "_")
+	return d
+}
+
+func PrometheusLabel() string {
+	return strings.ReplaceAll(String(), "\"", "")
+}