mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 02:44:59 +00:00
stdout/stderr around
A lot of functions in the cmd and ctl packages were passing these
around and barely using them. Replaced them with a logger for most
functions. Some functions get an io.Writer instead so that their
tests can find the output they're looking for.
More cleanup on fb-1766: reworked the tests that were using io.Pipe
or os.Pipe to check their results so they now use a bytes.Buffer.
Unexported some variables that didn't need to be exported.
Fixed NewConfigCommand to use the provided stderr, not os.Stderr.
Added tests for rbf_dump, rbf_page, and keygen, since those weren't
being tested at all.
Added chksum_test, final cleanup.
(cherry picked from commit f627199acb)
554 lines
13 KiB
Go
554 lines
13 KiB
Go
package ctl
|
|
|
|
import (
|
|
"bytes"
|
|
"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"
|
|
featurebase "github.com/molecula/featurebase/v3"
|
|
"github.com/molecula/featurebase/v3/dax"
|
|
queryerhttp "github.com/molecula/featurebase/v3/dax/queryer/http"
|
|
"github.com/molecula/featurebase/v3/fbcloud"
|
|
"github.com/molecula/featurebase/v3/logger"
|
|
"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"`
|
|
|
|
// 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 FBQueryer
|
|
}
|
|
|
|
func NewCLICommand(logdest logger.Logger) *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",
|
|
HistoryPath: historyPath,
|
|
|
|
OrganizationID: "",
|
|
DatabaseID: "",
|
|
}
|
|
}
|
|
|
|
// printQualifiers displays the currently set OrganizationID and DatabaseID.
|
|
func (cmd *CLICommand) printQualifiers() {
|
|
fmt.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 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:
|
|
fmt.Println("Detected standard deployment")
|
|
cmd.queryer = &standardQueryer{
|
|
Host: cmd.Host,
|
|
Port: cmd.Port,
|
|
}
|
|
case featurebaseTypeDAX:
|
|
fmt.Println("Detected dax deployment")
|
|
cmd.queryer = &daxQueryer{
|
|
Host: cmd.Host,
|
|
Port: cmd.Port,
|
|
}
|
|
case featurebaseTypeCloud:
|
|
fmt.Println("Detected cloud deployment")
|
|
cmd.queryer = &fbcloud.Queryer{
|
|
Host: cmd.Host,
|
|
|
|
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 {
|
|
// 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,
|
|
},
|
|
)
|
|
}
|
|
|
|
for _, trial := range trials {
|
|
url := hostPort(cmd.Host, trial.port) + trial.health
|
|
if resp, err := http.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.
|
|
fmt.Print(splash)
|
|
err := cmd.setupClient()
|
|
if 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,
|
|
})
|
|
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.
|
|
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
|
|
}
|
|
}
|
|
|
|
type FBQueryer interface {
|
|
Query(org, db, sql string) (*featurebase.WireQueryResponse, error)
|
|
}
|
|
|
|
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 {
|
|
fmt.Printf("making query: %v\n", err)
|
|
continue
|
|
}
|
|
err = writeOut(sqlResponse, os.Stdout)
|
|
if err != nil {
|
|
return errors.Wrap(err, "writing out response")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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, w io.Writer) error {
|
|
if r == nil {
|
|
return errors.New("attempt to write out nil response")
|
|
}
|
|
if r.Error != "" {
|
|
if _, err := w.Write([]byte("Error: " + r.Error + "\n")); err != nil {
|
|
return errors.Wrapf(err, "writing error: %s", r.Error)
|
|
}
|
|
return writeWarnings(r, 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 := writeWarnings(r, 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.WireQuerySchema) []interface{} {
|
|
ret := make([]interface{}, len(schema.Fields))
|
|
for i, field := range schema.Fields {
|
|
ret[i] = field.Name
|
|
}
|
|
return ret
|
|
}
|
|
|
|
// Ensure type implements interface.
|
|
var _ FBQueryer = (*standardQueryer)(nil)
|
|
|
|
// standardQueryer supports a standard featurebase deployment hitting the /sql
|
|
// endpoint with a payload containing only the sql statement.
|
|
type standardQueryer struct {
|
|
Host string
|
|
Port string
|
|
}
|
|
|
|
func (qryr *standardQueryer) Query(org, db, sql string) (*featurebase.WireQueryResponse, error) {
|
|
buf := bytes.Buffer{}
|
|
url := fmt.Sprintf("%s/sql", hostPort(qryr.Host, qryr.Port))
|
|
|
|
buf.Write([]byte(sql))
|
|
|
|
resp, err := http.Post(url, "application/json", &buf)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "posting query")
|
|
}
|
|
|
|
fullbod, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "reading response")
|
|
}
|
|
sqlResponse := &featurebase.WireQueryResponse{}
|
|
// TODO(tlt): switch this back once all responses are typed
|
|
// if err := json.Unmarshal(fullbod, sqlResponse); err != nil {
|
|
if err := sqlResponse.UnmarshalJSONTyped(fullbod, true); err != nil {
|
|
return nil, errors.Wrapf(err, "unmarshaling query response, body:\n'%s'\n", fullbod)
|
|
}
|
|
|
|
return sqlResponse, nil
|
|
}
|
|
|
|
// Ensure type implements interface.
|
|
var _ FBQueryer = (*daxQueryer)(nil)
|
|
|
|
// daxQueryer is similar to the standardQueryer except that it hits a different
|
|
// endpoint, and its payload is a json object which includes, in addition to the
|
|
// sql statement, things like org and db.
|
|
type daxQueryer struct {
|
|
Host string
|
|
Port string
|
|
}
|
|
|
|
func (qryr *daxQueryer) Query(org, db, sql string) (*featurebase.WireQueryResponse, error) {
|
|
buf := bytes.Buffer{}
|
|
url := fmt.Sprintf("%s/queryer/sql", hostPort(qryr.Host, qryr.Port))
|
|
|
|
sqlReq := &queryerhttp.SQLRequest{
|
|
OrganizationID: dax.OrganizationID(org),
|
|
DatabaseID: dax.DatabaseID(db),
|
|
SQL: sql,
|
|
}
|
|
if err := json.NewEncoder(&buf).Encode(sqlReq); err != nil {
|
|
return nil, errors.Wrapf(err, "encoding sql request: %s", sql)
|
|
}
|
|
|
|
resp, err := http.Post(url, "application/json", &buf)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "posting query")
|
|
}
|
|
|
|
fullbod, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "reading response")
|
|
}
|
|
sqlResponse := &featurebase.WireQueryResponse{}
|
|
// TODO(tlt): switch this back once all responses are typed
|
|
// if err := json.Unmarshal(fullbod, sqlResponse); err != nil {
|
|
if err := sqlResponse.UnmarshalJSONTyped(fullbod, true); err != nil {
|
|
return nil, errors.Wrapf(err, "unmarshaling query response, body:\n'%s'\n", fullbod)
|
|
}
|
|
|
|
return sqlResponse, nil
|
|
}
|