mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 10:54:59 +00:00
"featurebase cli" will now save command history to $HOME/.featurebase/cli_history by default. Additionally if a command is entered across multiple lines, the newlines will be removed when the command is saved in the history. Previously each line was saved separately which was a bit annoying.
285 lines
6.7 KiB
Go
285 lines
6.7 KiB
Go
package ctl
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"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 := ioutil.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
|
|
}
|