Explorar el Código

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

Danilo Fragoso hace 3 meses
padre
commit
dbcff0bb39
Se han modificado 10 ficheros con 314 adiciones y 12 borrados
  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
 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 the project
 build:
 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 pizzasql to PREFIX/bin (default: /usr/local/bin)
 install: build
 install: build
@@ -18,7 +23,7 @@ uninstall:
 	@echo "Removed $(DESTDIR)$(PREFIX)/bin/pizzasql"
 	@echo "Removed $(DESTDIR)$(PREFIX)/bin/pizzasql"
 
 
 build-linux-amd64:
 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
 # Run all tests
 test:
 test:

BIN
bin/pizzasql


+ 1 - 0
go.mod

@@ -3,6 +3,7 @@ module github.com/danfragoso/pizzasql-next
 go 1.25.0
 go 1.25.0
 
 
 require (
 require (
+	github.com/chzyer/readline v1.5.1 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/goccy/go-json v0.10.6 // indirect
 	github.com/goccy/go-json v0.10.6 // indirect
 	github.com/google/uuid v1.6.0 // 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 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 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
 github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
 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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
 github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
 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=
 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=
 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 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
 golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
 golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=

+ 198 - 6
main.go

@@ -5,13 +5,17 @@ import (
 	"context"
 	"context"
 	"flag"
 	"flag"
 	"fmt"
 	"fmt"
+	"io"
 	"net/http"
 	"net/http"
 	"os"
 	"os"
 	"os/signal"
 	"os/signal"
+	"path/filepath"
 	"strings"
 	"strings"
 	"syscall"
 	"syscall"
 	"time"
 	"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/csvexport"
 	"github.com/danfragoso/pizzasql-next/pkg/csvimport"
 	"github.com/danfragoso/pizzasql-next/pkg/csvimport"
 	"github.com/danfragoso/pizzasql-next/pkg/executor"
 	"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/sqlimport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqliteimport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqliteimport"
 	"github.com/danfragoso/pizzasql-next/pkg/storage"
 	"github.com/danfragoso/pizzasql-next/pkg/storage"
+	"github.com/danfragoso/pizzasql-next/pkg/version"
 )
 )
 
 
 var (
 var (
@@ -209,6 +214,7 @@ func executePipe() {
 
 
 func runREPL() {
 func runREPL() {
 	fmt.Println("PizzaSQL - SQL-92 compatible database")
 	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("Type 'help' for usage, 'quit' to exit")
 	fmt.Println()
 	fmt.Println()
 
 
@@ -231,20 +237,46 @@ func runREPL() {
 		fmt.Printf("Connected to PizzaKV at %s (database: %s)\n\n", *kvAddr, *database)
 		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
 	var sqlBuffer strings.Builder
 
 
 	for {
 	for {
 		if sqlBuffer.Len() == 0 {
 		if sqlBuffer.Len() == 0 {
-			fmt.Print("pizzasql> ")
+			rl.SetPrompt("pizzasql> ")
 		} else {
 		} else {
-			fmt.Print("       -> ")
+			rl.SetPrompt("       -> ")
 		}
 		}
 
 
-		line, err := reader.ReadString('\n')
+		line, err := rl.Readline()
 		if err != nil {
 		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)
 		line = strings.TrimSpace(line)
@@ -271,6 +303,12 @@ func runREPL() {
 			sqlBuffer.Reset()
 			sqlBuffer.Reset()
 			fmt.Println("Buffer cleared")
 			fmt.Println("Buffer cleared")
 			continue
 			continue
+		case "status", "\\s":
+			printStatus(exec != nil)
+			continue
+		case "functions", "\\df":
+			printFunctions()
+			continue
 		}
 		}
 
 
 		// Skip empty lines and comments
 		// 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) {
 func executeSQL(exec *executor.Executor, sql string) (*executor.Result, error) {
 	l := lexer.New(sql)
 	l := lexer.New(sql)
 	p := parser.New(l)
 	p := parser.New(l)
@@ -445,6 +598,13 @@ func evalExprSimple(expr parser.Expr) (interface{}, error) {
 		return val, nil
 		return val, nil
 	case *parser.ParenExpr:
 	case *parser.ParenExpr:
 		return evalExprSimple(e.Expr)
 		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)
 	return nil, fmt.Errorf("unsupported expression type: %T", expr)
 }
 }
@@ -528,6 +688,8 @@ func printHelp() {
 	fmt.Println("  quit, \\q     Exit the program")
 	fmt.Println("  quit, \\q     Exit the program")
 	fmt.Println("  tables, \\dt  List all tables")
 	fmt.Println("  tables, \\dt  List all tables")
 	fmt.Println("  clear, \\c    Clear the input buffer")
 	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()
 	fmt.Println("SQL Statements (end with semicolon):")
 	fmt.Println("SQL Statements (end with semicolon):")
 	fmt.Println("  SELECT ... FROM ... WHERE ...")
 	fmt.Println("  SELECT ... FROM ... WHERE ...")
@@ -572,6 +734,36 @@ func listTables(schema *storage.SchemaManager) {
 		fmt.Printf("  %s\n", t)
 		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() {
 func runExport() {
 	// Connect to PizzaKV
 	// Connect to PizzaKV
 	pool, err := storage.NewKVPool(*kvAddr, *poolSize, *timeout)
 	pool, err := storage.NewKVPool(*kvAddr, *poolSize, *timeout)

+ 17 - 1
pkg/analyzer/types.go

@@ -1,6 +1,9 @@
 package analyzer
 package analyzer
 
 
-import "strings"
+import (
+	"sort"
+	"strings"
+)
 
 
 // Type represents a SQL type with SQLite affinity rules.
 // Type represents a SQL type with SQLite affinity rules.
 type Type int
 type Type int
@@ -229,6 +232,7 @@ var builtinFunctions = map[string]FunctionSignature{
 
 
 	// SQLite specific
 	// SQLite specific
 	"SQLITE_VERSION": {Name: "SQLITE_VERSION", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeText, IsAggregate: false},
 	"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},
 	"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},
 	"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},
 	"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
 	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.
 // ColumnInfo describes a column in a table.
 type ColumnInfo struct {
 type ColumnInfo struct {
 	Name       string
 	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/lexer"
 	"github.com/danfragoso/pizzasql-next/pkg/parser"
 	"github.com/danfragoso/pizzasql-next/pkg/parser"
 	"github.com/danfragoso/pizzasql-next/pkg/storage"
 	"github.com/danfragoso/pizzasql-next/pkg/storage"
+	"github.com/danfragoso/pizzasql-next/pkg/version"
 )
 )
 
 
 // Executor executes SQL statements.
 // Executor executes SQL statements.
@@ -3012,7 +3013,7 @@ func (e *Executor) pragmaDatabaseList() (*Result, error) {
 func (e *Executor) pragmaVersion() (*Result, error) {
 func (e *Executor) pragmaVersion() (*Result, error) {
 	result := NewResult("PRAGMA")
 	result := NewResult("PRAGMA")
 	result.AddColumn("version")
 	result.AddColumn("version")
-	result.AddRow("PizzaSQL 1.0.0")
+	result.AddRow(version.String())
 	return result, nil
 	return result, nil
 }
 }
 
 
@@ -3858,6 +3859,8 @@ func (e *Executor) evalFunctionCall(fn *parser.FunctionCall, row storage.Row) (i
 		return evalStrftimeFunc(args)
 		return evalStrftimeFunc(args)
 	case "TIMEDIFF":
 	case "TIMEDIFF":
 		return evalTimediffFunc(args)
 		return evalTimediffFunc(args)
+	case "PIZZASQL_VERSION", "SQLITE_VERSION":
+		return version.String(), nil
 	}
 	}
 
 
 	return nil, 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/lexer"
 	"github.com/danfragoso/pizzasql-next/pkg/parser"
 	"github.com/danfragoso/pizzasql-next/pkg/parser"
 	"github.com/danfragoso/pizzasql-next/pkg/storage"
 	"github.com/danfragoso/pizzasql-next/pkg/storage"
+	"github.com/danfragoso/pizzasql-next/pkg/version"
 )
 )
 
 
 func parse(t *testing.T, sql string) parser.Statement {
 func parse(t *testing.T, sql string) parser.Statement {
@@ -596,6 +597,7 @@ func TestEvalSQLiteFunctions(t *testing.T) {
 		// CONCAT
 		// CONCAT
 		{"CONCAT('hello', ' ', 'world')", "hello world", false},
 		{"CONCAT('hello', ' ', 'world')", "hello world", false},
 		{"CONCAT('a', 'b', 'c')", "abc", false},
 		{"CONCAT('a', 'b', 'c')", "abc", false},
+		{"PIZZASQL_VERSION()", version.String(), false},
 
 
 		// MAX/MIN (scalar versions)
 		// MAX/MIN (scalar versions)
 		{"MAX(1, 5, 3)", int64(5), false},
 		{"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/sqlexport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqlimport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqlimport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqliteimport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqliteimport"
+	"github.com/danfragoso/pizzasql-next/pkg/version"
 )
 )
 
 
 // QueryRequest represents a single query request.
 // 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) {
 func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
 	resp := map[string]interface{}{
 	resp := map[string]interface{}{
 		"status":  "ok",
 		"status":  "ok",
-		"version": "0.1.0",
+		"version": version.String(),
 		"uptime":  time.Since(s.stats.StartTime).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, "# HELP pizzasql_info PizzaSQL server information\n")
 	fmt.Fprintf(w, "# TYPE pizzasql_info gauge\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
 // 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(), "\"", "")
+}