mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 10:54:59 +00:00
This commit moves the cli out of the `ctl` package and into its own `cli` package. It also adds some basic tests for expected input. Finally, it fixes a bug which was causing intentional line feeds to be ignored, which was a problem with the BULK INSERT command.
542 lines
13 KiB
Go
542 lines
13 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/chzyer/readline"
|
|
"github.com/jedib0t/go-pretty/table"
|
|
"github.com/jedib0t/go-pretty/text"
|
|
featurebase "github.com/molecula/featurebase/v3"
|
|
"github.com/molecula/featurebase/v3/cli/fbcloud"
|
|
"github.com/molecula/featurebase/v3/logger"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
const (
|
|
defaultHost string = "localhost"
|
|
promptBegin string = "fbsql> "
|
|
promptMid string = " -> "
|
|
terminationChar string = ";"
|
|
exitCommand string = "exit"
|
|
nullValue string = "NULL"
|
|
)
|
|
|
|
var (
|
|
Stdin io.ReadCloser = os.Stdin
|
|
Stdout io.Writer = os.Stdout
|
|
Stderr io.Writer = os.Stderr
|
|
)
|
|
|
|
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"`
|
|
|
|
// Cloud Auth
|
|
ClientID string `json:"client-id"`
|
|
Region string `json:"region"`
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
|
|
// commands holds the list of sql commands to be executed.
|
|
commands []string
|
|
|
|
OrganizationID string `json:"org-id"`
|
|
DatabaseID string `json:"db-id"`
|
|
|
|
Queryer Queryer `json:"-"`
|
|
|
|
Stdin io.ReadCloser `json:"-"`
|
|
Stdout io.Writer `json:"-"`
|
|
Stderr io.Writer `json:"-"`
|
|
}
|
|
|
|
func NewCLICommand(logdest logger.Logger) *CLICommand {
|
|
return &CLICommand{
|
|
Host: defaultHost,
|
|
HistoryPath: "",
|
|
|
|
OrganizationID: "",
|
|
DatabaseID: "",
|
|
|
|
Stdin: Stdin,
|
|
Stdout: Stdout,
|
|
Stderr: Stderr,
|
|
}
|
|
}
|
|
|
|
func (cmd *CLICommand) setupHistory() {
|
|
// If HistoryPath has already been configured (i.e. with a command flag),
|
|
// don't bother setting up the default in the home directory.
|
|
if cmd.HistoryPath != "" {
|
|
return
|
|
}
|
|
|
|
historyPath := ""
|
|
if home, err := os.UserHomeDir(); err != nil {
|
|
cmd.Printf("Error getting home directory, command history persistence will be disabled: %v\n", err)
|
|
} else {
|
|
historyDir := filepath.Join(home, ".featurebase")
|
|
err := os.MkdirAll(historyDir, 0o750)
|
|
if err != nil {
|
|
cmd.Printf("Creating directory for history: %v\n", err)
|
|
} else {
|
|
historyPath = filepath.Join(historyDir, "cli_history")
|
|
}
|
|
}
|
|
cmd.HistoryPath = historyPath
|
|
}
|
|
|
|
// printQualifiers displays the currently set OrganizationID and DatabaseID.
|
|
func (cmd *CLICommand) printQualifiers() {
|
|
cmd.Printf(" Host: %s\n Org: %s\n DB: %s\n",
|
|
hostPort(cmd.Host, cmd.Port),
|
|
cmd.OrganizationID,
|
|
cmd.DatabaseID,
|
|
)
|
|
}
|
|
|
|
func (cmd *CLICommand) setupClient() error {
|
|
// If the Queryer has already been set (in tests for example), don't bother
|
|
// trying to detect it.
|
|
if cmd.Queryer != nil {
|
|
return nil
|
|
}
|
|
|
|
if strings.TrimSpace(cmd.Host) == "" {
|
|
return errors.Errorf("no host provided")
|
|
}
|
|
|
|
if !strings.HasPrefix(cmd.Host, "http") {
|
|
cmd.Host = "http://" + cmd.Host
|
|
}
|
|
|
|
typ, err := cmd.detectFBType()
|
|
if err != nil {
|
|
return errors.Wrap(err, "detecting FeatureBase deployment type")
|
|
}
|
|
|
|
switch typ {
|
|
case featurebaseTypeStandard:
|
|
cmd.Printf("Detected standard deployment\n")
|
|
cmd.Queryer = &standardQueryer{
|
|
Host: cmd.Host,
|
|
Port: cmd.Port,
|
|
}
|
|
case featurebaseTypeDAX:
|
|
cmd.Printf("Detected dax deployment\n")
|
|
cmd.Queryer = &daxQueryer{
|
|
Host: cmd.Host,
|
|
Port: cmd.Port,
|
|
}
|
|
case featurebaseTypeCloud:
|
|
cmd.Printf("Detected cloud deployment\n")
|
|
cmd.Queryer = &fbcloud.Queryer{
|
|
Host: hostPort(cmd.Host, cmd.Port),
|
|
|
|
ClientID: cmd.ClientID,
|
|
Region: cmd.Region,
|
|
Email: cmd.Email,
|
|
Password: cmd.Password,
|
|
}
|
|
default:
|
|
return errors.Errorf("unknown type: %s", typ)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type featurebaseType string
|
|
|
|
const (
|
|
featurebaseTypeStandard featurebaseType = "standard"
|
|
featurebaseTypeDAX featurebaseType = "dax"
|
|
featurebaseTypeCloud featurebaseType = "cloud"
|
|
)
|
|
|
|
func hostPort(host, port string) string {
|
|
if port == "" {
|
|
return host
|
|
}
|
|
return host + ":" + port
|
|
}
|
|
|
|
// detectFBType determines if we're talking to standalone FeatureBase
|
|
// or FeatureBase Cloud
|
|
func (cmd *CLICommand) detectFBType() (featurebaseType, error) {
|
|
type trial struct {
|
|
port string
|
|
health string
|
|
typ featurebaseType
|
|
}
|
|
|
|
// trials is populated with the url/endpoints to try in order to detect if a
|
|
// process is running there which can support the cli requests.
|
|
trials := []trial{}
|
|
|
|
if cmd.Port != "" {
|
|
trials = append(trials,
|
|
// dax
|
|
trial{
|
|
port: cmd.Port,
|
|
health: "/queryer/health",
|
|
typ: featurebaseTypeDAX,
|
|
},
|
|
// standard
|
|
trial{
|
|
port: cmd.Port,
|
|
health: "/status",
|
|
typ: featurebaseTypeStandard,
|
|
},
|
|
)
|
|
} else if strings.HasPrefix(cmd.Host, "https") {
|
|
// https suggesting we might be connecting to a cloud host
|
|
trials = append(trials,
|
|
// cloud
|
|
trial{
|
|
port: "",
|
|
health: "health",
|
|
typ: featurebaseTypeCloud,
|
|
},
|
|
)
|
|
} else {
|
|
// Try default ports just in case.
|
|
trials = append(trials,
|
|
// dax
|
|
trial{
|
|
port: "8080",
|
|
health: "/queryer/health",
|
|
typ: featurebaseTypeDAX,
|
|
},
|
|
// standard
|
|
trial{
|
|
port: "10101",
|
|
health: "/status",
|
|
typ: featurebaseTypeStandard,
|
|
},
|
|
)
|
|
}
|
|
|
|
client := http.Client{
|
|
Timeout: 100 * time.Millisecond,
|
|
}
|
|
for _, trial := range trials {
|
|
url := hostPort(cmd.Host, trial.port) + trial.health
|
|
if resp, err := client.Get(url); err != nil {
|
|
continue
|
|
} else if resp.StatusCode/100 == 2 {
|
|
cmd.Port = trial.port
|
|
return trial.typ, nil
|
|
}
|
|
}
|
|
|
|
return featurebaseTypeCloud, nil
|
|
}
|
|
|
|
func (cmd *CLICommand) Run(ctx context.Context) error {
|
|
// Print the splash message.
|
|
cmd.Printf(splash)
|
|
cmd.setupHistory()
|
|
if err := cmd.setupClient(); err != nil {
|
|
return errors.Wrap(err, "setting up client")
|
|
}
|
|
cmd.printQualifiers()
|
|
|
|
rl, err := readline.NewEx(&readline.Config{
|
|
Prompt: promptBegin,
|
|
HistoryFile: cmd.HistoryPath,
|
|
HistoryLimit: 100000,
|
|
DisableAutoSaveHistory: true,
|
|
|
|
Stdin: cmd.Stdin,
|
|
Stdout: cmd.Stdout,
|
|
Stderr: cmd.Stderr,
|
|
})
|
|
if err != nil {
|
|
return errors.Wrap(err, "getting readline")
|
|
}
|
|
defer rl.Close()
|
|
|
|
// 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.
|
|
cmd.Printf("\n")
|
|
}
|
|
|
|
// Read user provided input.
|
|
line, err := rl.Readline()
|
|
if err != nil {
|
|
return errors.Wrap(err, "reading line")
|
|
}
|
|
|
|
// We append a line feed at the end of each line because at this point
|
|
// we have effectively stripped any intentional line feeds (since we are
|
|
// reading a line at a time), and we don't want to do that. An example
|
|
// of an intentional line feed is in a BULK INSERT CSV STREAM like this
|
|
// example:
|
|
//
|
|
// bulk replace
|
|
// into foo (_id, age)
|
|
// map (0 id, 1 int)
|
|
// from
|
|
// x'3,33
|
|
// 4,44
|
|
// 5,55'
|
|
// with
|
|
// format 'CSV'
|
|
// input 'STREAM';
|
|
//
|
|
// We want to preserve the line feeds that are contained in the x''
|
|
// block; those are intentional as they demarc records within the csv.
|
|
line += "\n"
|
|
|
|
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 := strings.TrimSpace(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 {
|
|
cmd.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 {
|
|
// Handle non-sql commands (for example, SET commands).
|
|
if handled, err := cmd.handleIfNonSQLCommand(ctx, sql); err != nil {
|
|
return errors.Wrapf(err, "handling non-SQL command: %s", sql)
|
|
} else if handled {
|
|
continue
|
|
}
|
|
|
|
sqlResponse, err := cmd.Queryer.Query(cmd.OrganizationID, cmd.DatabaseID, sql)
|
|
if err != nil {
|
|
cmd.Printf("making query: %v\n", err)
|
|
continue
|
|
}
|
|
err = writeOut(sqlResponse, cmd.Stdout, cmd.Stderr)
|
|
if err != nil {
|
|
return errors.Wrap(err, "writing out response")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Printf is a helper method which sends the given payload to stdout.
|
|
func (cmd *CLICommand) Printf(format string, a ...any) {
|
|
out := fmt.Sprintf(format, a...)
|
|
cmd.Stdout.Write([]byte(out))
|
|
}
|
|
|
|
// handleIfNonSQLCommand will handle special case command like "SET ..." and
|
|
// "USE ...". If the sql command matches one of these conditions and is handled,
|
|
// the bool returned will be true;
|
|
func (cmd *CLICommand) handleIfNonSQLCommand(ctx context.Context, sql string) (bool, error) {
|
|
var handled bool
|
|
|
|
// Get the first token from the SQL:
|
|
parts := strings.Split(sql, " ")
|
|
if len(parts) < 1 {
|
|
return handled, nil
|
|
}
|
|
|
|
token := strings.ToUpper(parts[0])
|
|
|
|
// Supported:
|
|
// SET ORG acme
|
|
// SET DB db1
|
|
// USE db1
|
|
switch token {
|
|
case "SET":
|
|
handled = true
|
|
switch len(parts) {
|
|
case 1:
|
|
// This will fall through and just print the qualifiers.
|
|
case 3:
|
|
switch strings.ToUpper(parts[1]) {
|
|
case "HOST":
|
|
cmd.Host = parts[2]
|
|
case "ORG":
|
|
cmd.OrganizationID = parts[2]
|
|
case "DB":
|
|
cmd.DatabaseID = parts[2]
|
|
}
|
|
default:
|
|
return handled, errors.Errorf("SET command takes a name and a value (SET DB db1)")
|
|
}
|
|
|
|
case "USE":
|
|
handled = true
|
|
if len(parts) != 2 {
|
|
return handled, errors.Errorf("USE command takes a single value (USE db1)")
|
|
}
|
|
cmd.DatabaseID = parts[1]
|
|
default:
|
|
return handled, nil
|
|
}
|
|
|
|
cmd.printQualifiers()
|
|
return handled, nil
|
|
}
|
|
|
|
func writeWarnings(r *featurebase.WireQueryResponse, 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 writeOut(r *featurebase.WireQueryResponse, wOut io.Writer, wErr io.Writer) error {
|
|
if r == nil {
|
|
return errors.New("attempt to write out nil response")
|
|
}
|
|
if r.Error != "" {
|
|
if _, err := wErr.Write([]byte("Error: " + r.Error + "\n")); err != nil {
|
|
return errors.Wrapf(err, "writing error: %s", r.Error)
|
|
}
|
|
return writeWarnings(r, wOut)
|
|
}
|
|
|
|
t := table.NewWriter()
|
|
t.SetOutputMirror(wOut)
|
|
|
|
// 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 := writeWarnings(r, wOut)
|
|
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 := wOut.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.WireQuerySchema) []interface{} {
|
|
ret := make([]interface{}, len(schema.Fields))
|
|
for i, field := range schema.Fields {
|
|
ret[i] = field.Name
|
|
}
|
|
return ret
|
|
}
|