mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 02:44:59 +00:00
The ioutil package is deprecated, with all of its functions having moved into os or io. Do the replacements so we stop having this impending.
284 lines
6.7 KiB
Go
284 lines
6.7 KiB
Go
package ctl
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/chzyer/readline"
|
|
featurebase "github.com/featurebasedb/featurebase/v3"
|
|
"github.com/jedib0t/go-pretty/table"
|
|
"github.com/jedib0t/go-pretty/text"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
const (
|
|
promptBegin string = "fbsql> "
|
|
promptMid string = " -> "
|
|
terminationChar string = ";"
|
|
exitCommand string = "exit"
|
|
nullValue string = "NULL"
|
|
)
|
|
|
|
var (
|
|
splash string = fmt.Sprintf(`FeatureBase CLI (%s)
|
|
Type "exit" to quit.
|
|
`, featurebase.Version)
|
|
)
|
|
|
|
type CLICommand struct {
|
|
Host string `json:"host"`
|
|
Port string `json:"port"`
|
|
HistoryPath string `json:"history-path"`
|
|
|
|
// commands holds the list of sql commands to be executed.
|
|
commands []string
|
|
}
|
|
|
|
func NewCLICommand(stdin io.Reader, stdout, stderr io.Writer) *CLICommand {
|
|
historyPath := ""
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Printf("Error getting home directory, command history persistence will be disabled: %v\n", err)
|
|
} else {
|
|
historyDir := filepath.Join(home, ".featurebase")
|
|
err := os.MkdirAll(historyDir, 0750)
|
|
if err != nil {
|
|
fmt.Printf("Creating directory for history: %v\n", err)
|
|
} else {
|
|
historyPath = filepath.Join(historyDir, "cli_history")
|
|
}
|
|
}
|
|
return &CLICommand{
|
|
Host: "localhost",
|
|
Port: "10101",
|
|
HistoryPath: historyPath,
|
|
}
|
|
}
|
|
|
|
func (cmd *CLICommand) Run(ctx context.Context) error {
|
|
// Print the splash message.
|
|
fmt.Print(splash)
|
|
|
|
rl, err := readline.NewEx(&readline.Config{
|
|
Prompt: promptBegin,
|
|
HistoryFile: cmd.HistoryPath,
|
|
HistoryLimit: 100000,
|
|
DisableAutoSaveHistory: true,
|
|
})
|
|
if err != nil {
|
|
return errors.Wrap(err, "getting readline")
|
|
}
|
|
defer rl.Close()
|
|
|
|
if !strings.HasPrefix(cmd.Host, "http") {
|
|
cmd.Host = "http://" + cmd.Host
|
|
}
|
|
|
|
// partialCommand holds all input prior to receiving a termination
|
|
// character.
|
|
var partialCommand string
|
|
|
|
// inMidCommand indicates whether a partial command has been received and
|
|
// we're still waiting for a termination character.
|
|
var inMidCommand bool
|
|
|
|
for {
|
|
if inMidCommand {
|
|
rl.SetPrompt(promptMid)
|
|
} else {
|
|
rl.SetPrompt(promptBegin)
|
|
// Add some white space before each new prompt.
|
|
fmt.Println()
|
|
}
|
|
|
|
// Read user provided input.
|
|
line, err := rl.Readline()
|
|
if err != nil {
|
|
return errors.Wrap(err, "reading line")
|
|
}
|
|
|
|
if !inMidCommand {
|
|
// Handle the exit command.
|
|
if line == exitCommand || line == exitCommand+terminationChar {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Look for a termination character;
|
|
parts := strings.Split(line, terminationChar)
|
|
|
|
// Length of 1 means a termination character was not received.
|
|
if len(parts) == 1 {
|
|
if parts[0] != "" {
|
|
partialCommand = appendCommand(partialCommand, parts[0])
|
|
inMidCommand = true
|
|
}
|
|
continue
|
|
}
|
|
|
|
for i, part := range parts {
|
|
partIsFinal := i == len(parts)-1
|
|
partIsBlank := part == ""
|
|
|
|
if partIsBlank && partIsFinal {
|
|
continue
|
|
}
|
|
|
|
if partIsBlank && !partIsFinal {
|
|
if inMidCommand {
|
|
cmd.commands = append(cmd.commands, strings.TrimSpace(partialCommand))
|
|
partialCommand = ""
|
|
inMidCommand = false
|
|
}
|
|
continue
|
|
}
|
|
|
|
if !partIsBlank && partIsFinal {
|
|
partialCommand = part
|
|
inMidCommand = true
|
|
continue
|
|
}
|
|
|
|
if !partIsBlank && !partIsFinal {
|
|
partialCommand = appendCommand(partialCommand, part)
|
|
cmd.commands = append(cmd.commands, strings.TrimSpace(partialCommand))
|
|
partialCommand = ""
|
|
inMidCommand = false
|
|
}
|
|
}
|
|
|
|
err = rl.SaveHistory(strings.Join(cmd.commands, "; ") + ";")
|
|
if err != nil {
|
|
fmt.Printf("Couldn't save history: %v\n", err)
|
|
}
|
|
|
|
if err := cmd.executeCommands(ctx); err != nil {
|
|
return errors.Wrap(err, "executing commands")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func appendCommand(orig string, part string) string {
|
|
if orig == "" {
|
|
return part
|
|
} else {
|
|
return orig + " " + part
|
|
}
|
|
}
|
|
|
|
func (cmd *CLICommand) executeCommands(ctx context.Context) error {
|
|
// Clear out the buffered commands on any exit from this method.
|
|
defer func() {
|
|
cmd.commands = nil
|
|
}()
|
|
|
|
for _, sql := range cmd.commands {
|
|
resp, err := http.Post(fmt.Sprintf("%s:%s/sql", cmd.Host, cmd.Port), "application/sql", strings.NewReader(sql))
|
|
if err != nil {
|
|
return errors.Wrapf(err, "posting query")
|
|
}
|
|
|
|
var sqlResponse response
|
|
fullbod, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return errors.Wrap(err, "reading response")
|
|
}
|
|
err = json.Unmarshal(fullbod, &sqlResponse)
|
|
if err != nil {
|
|
fmt.Printf("couldn't decode response: %v\n", err)
|
|
fmt.Printf("%s\n", fullbod)
|
|
}
|
|
|
|
err = sqlResponse.WriteOut(os.Stdout)
|
|
if err != nil {
|
|
return errors.Wrap(err, "writing out response")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type response struct {
|
|
Schema featurebase.SQLSchema `json:"schema"`
|
|
Data [][]interface{} `json:"data"`
|
|
Error string `json:"error"`
|
|
Warnings []string `json:"warnings"`
|
|
ExecutionTime int64 `json:"exec_time"`
|
|
}
|
|
|
|
func (r *response) WriteWarnings(w io.Writer) error {
|
|
if len(r.Warnings) > 0 {
|
|
if _, err := w.Write([]byte("\n")); err != nil {
|
|
return errors.Wrapf(err, "writing warning: %s", r.Error)
|
|
}
|
|
for _, warning := range r.Warnings {
|
|
if _, err := w.Write([]byte("Warning: " + warning + "\n")); err != nil {
|
|
return errors.Wrapf(err, "writing warning: %s", r.Error)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *response) WriteOut(w io.Writer) error {
|
|
if r.Error != "" {
|
|
if _, err := w.Write([]byte("Error: " + r.Error + "\n")); err != nil {
|
|
return errors.Wrapf(err, "writing error: %s", r.Error)
|
|
}
|
|
return r.WriteWarnings(w)
|
|
}
|
|
|
|
t := table.NewWriter()
|
|
t.SetOutputMirror(w)
|
|
|
|
// Don't uppercase the header values.
|
|
t.Style().Format.Header = text.FormatDefault
|
|
|
|
t.AppendHeader(schemaToRow(r.Schema))
|
|
for _, row := range r.Data {
|
|
// If the value is nil, replace it with a null string; go-pretty doesn't
|
|
// expect nil pointers in the data values.
|
|
for i := range row {
|
|
if row[i] == nil {
|
|
row[i] = nullValue
|
|
}
|
|
}
|
|
t.AppendRow(table.Row(row))
|
|
}
|
|
t.Render()
|
|
|
|
err := r.WriteWarnings(w)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
lifeAffirmingMessage := ""
|
|
if r.ExecutionTime < 1000000 {
|
|
lifeAffirmingMessage = " (You're welcome! 🚀)"
|
|
}
|
|
|
|
if r.ExecutionTime > 5000000 {
|
|
lifeAffirmingMessage = " (Sorry! That took longer than expected 😭)"
|
|
}
|
|
|
|
if _, err := w.Write([]byte(fmt.Sprintf("\nExecution time: %dμs%s\n", r.ExecutionTime, lifeAffirmingMessage))); err != nil {
|
|
return errors.Wrapf(err, "writing execution time: %s", r.Error)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func schemaToRow(schema featurebase.SQLSchema) []interface{} {
|
|
ret := make([]interface{}, len(schema.Fields))
|
|
for i, field := range schema.Fields {
|
|
ret[i] = field.Name
|
|
}
|
|
return ret
|
|
}
|