mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 02:44:59 +00:00
Refactor CLI (#2417)
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.
(cherry picked from commit cf72bfa16f)
This commit is contained in:
parent
f2812309ac
commit
f6a767befb
6 changed files with 407 additions and 136 deletions
|
|
@ -1,9 +1,7 @@
|
||||||
package ctl
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -32,6 +30,12 @@ const (
|
||||||
nullValue string = "NULL"
|
nullValue string = "NULL"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Stdin io.ReadCloser = os.Stdin
|
||||||
|
Stdout io.Writer = os.Stdout
|
||||||
|
Stderr io.Writer = os.Stderr
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
splash string = fmt.Sprintf(`FeatureBase CLI (%s)
|
splash string = fmt.Sprintf(`FeatureBase CLI (%s)
|
||||||
Type "exit" to quit.
|
Type "exit" to quit.
|
||||||
|
|
@ -55,35 +59,52 @@ type CLICommand struct {
|
||||||
OrganizationID string `json:"org-id"`
|
OrganizationID string `json:"org-id"`
|
||||||
DatabaseID string `json:"db-id"`
|
DatabaseID string `json:"db-id"`
|
||||||
|
|
||||||
queryer FBQueryer
|
Queryer Queryer `json:"-"`
|
||||||
|
|
||||||
|
Stdin io.ReadCloser `json:"-"`
|
||||||
|
Stdout io.Writer `json:"-"`
|
||||||
|
Stderr io.Writer `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCLICommand(logdest logger.Logger) *CLICommand {
|
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 := ""
|
historyPath := ""
|
||||||
home, err := os.UserHomeDir()
|
if home, err := os.UserHomeDir(); err != nil {
|
||||||
if err != nil {
|
cmd.Printf("Error getting home directory, command history persistence will be disabled: %v\n", err)
|
||||||
fmt.Printf("Error getting home directory, command history persistence will be disabled: %v\n", err)
|
|
||||||
} else {
|
} else {
|
||||||
historyDir := filepath.Join(home, ".featurebase")
|
historyDir := filepath.Join(home, ".featurebase")
|
||||||
err := os.MkdirAll(historyDir, 0750)
|
err := os.MkdirAll(historyDir, 0o750)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Creating directory for history: %v\n", err)
|
cmd.Printf("Creating directory for history: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
historyPath = filepath.Join(historyDir, "cli_history")
|
historyPath = filepath.Join(historyDir, "cli_history")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &CLICommand{
|
cmd.HistoryPath = historyPath
|
||||||
Host: defaultHost,
|
|
||||||
HistoryPath: historyPath,
|
|
||||||
|
|
||||||
OrganizationID: "",
|
|
||||||
DatabaseID: "",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// printQualifiers displays the currently set OrganizationID and DatabaseID.
|
// printQualifiers displays the currently set OrganizationID and DatabaseID.
|
||||||
func (cmd *CLICommand) printQualifiers() {
|
func (cmd *CLICommand) printQualifiers() {
|
||||||
fmt.Printf(" Host: %s\n Org: %s\n DB: %s\n",
|
cmd.Printf(" Host: %s\n Org: %s\n DB: %s\n",
|
||||||
hostPort(cmd.Host, cmd.Port),
|
hostPort(cmd.Host, cmd.Port),
|
||||||
cmd.OrganizationID,
|
cmd.OrganizationID,
|
||||||
cmd.DatabaseID,
|
cmd.DatabaseID,
|
||||||
|
|
@ -91,6 +112,12 @@ func (cmd *CLICommand) printQualifiers() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cmd *CLICommand) setupClient() error {
|
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) == "" {
|
if strings.TrimSpace(cmd.Host) == "" {
|
||||||
return errors.Errorf("no host provided")
|
return errors.Errorf("no host provided")
|
||||||
}
|
}
|
||||||
|
|
@ -106,20 +133,20 @@ func (cmd *CLICommand) setupClient() error {
|
||||||
|
|
||||||
switch typ {
|
switch typ {
|
||||||
case featurebaseTypeStandard:
|
case featurebaseTypeStandard:
|
||||||
fmt.Println("Detected standard deployment")
|
cmd.Printf("Detected standard deployment\n")
|
||||||
cmd.queryer = &standardQueryer{
|
cmd.Queryer = &standardQueryer{
|
||||||
Host: cmd.Host,
|
Host: cmd.Host,
|
||||||
Port: cmd.Port,
|
Port: cmd.Port,
|
||||||
}
|
}
|
||||||
case featurebaseTypeDAX:
|
case featurebaseTypeDAX:
|
||||||
fmt.Println("Detected dax deployment")
|
cmd.Printf("Detected dax deployment\n")
|
||||||
cmd.queryer = &daxQueryer{
|
cmd.Queryer = &daxQueryer{
|
||||||
Host: cmd.Host,
|
Host: cmd.Host,
|
||||||
Port: cmd.Port,
|
Port: cmd.Port,
|
||||||
}
|
}
|
||||||
case featurebaseTypeCloud:
|
case featurebaseTypeCloud:
|
||||||
fmt.Println("Detected cloud deployment")
|
cmd.Printf("Detected cloud deployment\n")
|
||||||
cmd.queryer = &fbcloud.Queryer{
|
cmd.Queryer = &fbcloud.Queryer{
|
||||||
Host: hostPort(cmd.Host, cmd.Port),
|
Host: hostPort(cmd.Host, cmd.Port),
|
||||||
|
|
||||||
ClientID: cmd.ClientID,
|
ClientID: cmd.ClientID,
|
||||||
|
|
@ -222,9 +249,9 @@ func (cmd *CLICommand) detectFBType() (featurebaseType, error) {
|
||||||
|
|
||||||
func (cmd *CLICommand) Run(ctx context.Context) error {
|
func (cmd *CLICommand) Run(ctx context.Context) error {
|
||||||
// Print the splash message.
|
// Print the splash message.
|
||||||
fmt.Print(splash)
|
cmd.Printf(splash)
|
||||||
err := cmd.setupClient()
|
cmd.setupHistory()
|
||||||
if err != nil {
|
if err := cmd.setupClient(); err != nil {
|
||||||
return errors.Wrap(err, "setting up client")
|
return errors.Wrap(err, "setting up client")
|
||||||
}
|
}
|
||||||
cmd.printQualifiers()
|
cmd.printQualifiers()
|
||||||
|
|
@ -234,6 +261,10 @@ func (cmd *CLICommand) Run(ctx context.Context) error {
|
||||||
HistoryFile: cmd.HistoryPath,
|
HistoryFile: cmd.HistoryPath,
|
||||||
HistoryLimit: 100000,
|
HistoryLimit: 100000,
|
||||||
DisableAutoSaveHistory: true,
|
DisableAutoSaveHistory: true,
|
||||||
|
|
||||||
|
Stdin: cmd.Stdin,
|
||||||
|
Stdout: cmd.Stdout,
|
||||||
|
Stderr: cmd.Stderr,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "getting readline")
|
return errors.Wrap(err, "getting readline")
|
||||||
|
|
@ -254,7 +285,7 @@ func (cmd *CLICommand) Run(ctx context.Context) error {
|
||||||
} else {
|
} else {
|
||||||
rl.SetPrompt(promptBegin)
|
rl.SetPrompt(promptBegin)
|
||||||
// Add some white space before each new prompt.
|
// Add some white space before each new prompt.
|
||||||
fmt.Println()
|
cmd.Printf("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read user provided input.
|
// Read user provided input.
|
||||||
|
|
@ -263,6 +294,27 @@ func (cmd *CLICommand) Run(ctx context.Context) error {
|
||||||
return errors.Wrap(err, "reading line")
|
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 {
|
if !inMidCommand {
|
||||||
// Handle the exit command.
|
// Handle the exit command.
|
||||||
if line == exitCommand || line == exitCommand+terminationChar {
|
if line == exitCommand || line == exitCommand+terminationChar {
|
||||||
|
|
@ -284,7 +336,7 @@ func (cmd *CLICommand) Run(ctx context.Context) error {
|
||||||
|
|
||||||
for i, part := range parts {
|
for i, part := range parts {
|
||||||
partIsFinal := i == len(parts)-1
|
partIsFinal := i == len(parts)-1
|
||||||
partIsBlank := part == ""
|
partIsBlank := strings.TrimSpace(part) == ""
|
||||||
|
|
||||||
if partIsBlank && partIsFinal {
|
if partIsBlank && partIsFinal {
|
||||||
continue
|
continue
|
||||||
|
|
@ -315,7 +367,7 @@ func (cmd *CLICommand) Run(ctx context.Context) error {
|
||||||
|
|
||||||
err = rl.SaveHistory(strings.Join(cmd.commands, "; ") + ";")
|
err = rl.SaveHistory(strings.Join(cmd.commands, "; ") + ";")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Couldn't save history: %v\n", err)
|
cmd.Printf("Couldn't save history: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cmd.executeCommands(ctx); err != nil {
|
if err := cmd.executeCommands(ctx); err != nil {
|
||||||
|
|
@ -330,14 +382,10 @@ func appendCommand(orig string, part string) string {
|
||||||
if orig == "" {
|
if orig == "" {
|
||||||
return part
|
return part
|
||||||
} else {
|
} else {
|
||||||
return orig + " " + part
|
return orig + part
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type FBQueryer interface {
|
|
||||||
Query(org, db, sql string) (*featurebase.WireQueryResponse, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cmd *CLICommand) executeCommands(ctx context.Context) error {
|
func (cmd *CLICommand) executeCommands(ctx context.Context) error {
|
||||||
// Clear out the buffered commands on any exit from this method.
|
// Clear out the buffered commands on any exit from this method.
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|
@ -352,12 +400,12 @@ func (cmd *CLICommand) executeCommands(ctx context.Context) error {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlResponse, err := cmd.queryer.Query(cmd.OrganizationID, cmd.DatabaseID, sql)
|
sqlResponse, err := cmd.Queryer.Query(cmd.OrganizationID, cmd.DatabaseID, sql)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("making query: %v\n", err)
|
cmd.Printf("making query: %v\n", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
err = writeOut(sqlResponse, os.Stdout)
|
err = writeOut(sqlResponse, cmd.Stdout, cmd.Stderr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "writing out response")
|
return errors.Wrap(err, "writing out response")
|
||||||
}
|
}
|
||||||
|
|
@ -366,6 +414,12 @@ func (cmd *CLICommand) executeCommands(ctx context.Context) error {
|
||||||
return nil
|
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
|
// handleIfNonSQLCommand will handle special case command like "SET ..." and
|
||||||
// "USE ...". If the sql command matches one of these conditions and is handled,
|
// "USE ...". If the sql command matches one of these conditions and is handled,
|
||||||
// the bool returned will be true;
|
// the bool returned will be true;
|
||||||
|
|
@ -431,19 +485,19 @@ func writeWarnings(r *featurebase.WireQueryResponse, w io.Writer) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeOut(r *featurebase.WireQueryResponse, w io.Writer) error {
|
func writeOut(r *featurebase.WireQueryResponse, wOut io.Writer, wErr io.Writer) error {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return errors.New("attempt to write out nil response")
|
return errors.New("attempt to write out nil response")
|
||||||
}
|
}
|
||||||
if r.Error != "" {
|
if r.Error != "" {
|
||||||
if _, err := w.Write([]byte("Error: " + r.Error + "\n")); err != nil {
|
if _, err := wErr.Write([]byte("Error: " + r.Error + "\n")); err != nil {
|
||||||
return errors.Wrapf(err, "writing error: %s", r.Error)
|
return errors.Wrapf(err, "writing error: %s", r.Error)
|
||||||
}
|
}
|
||||||
return writeWarnings(r, w)
|
return writeWarnings(r, wOut)
|
||||||
}
|
}
|
||||||
|
|
||||||
t := table.NewWriter()
|
t := table.NewWriter()
|
||||||
t.SetOutputMirror(w)
|
t.SetOutputMirror(wOut)
|
||||||
|
|
||||||
// Don't uppercase the header values.
|
// Don't uppercase the header values.
|
||||||
t.Style().Format.Header = text.FormatDefault
|
t.Style().Format.Header = text.FormatDefault
|
||||||
|
|
@ -461,7 +515,7 @@ func writeOut(r *featurebase.WireQueryResponse, w io.Writer) error {
|
||||||
}
|
}
|
||||||
t.Render()
|
t.Render()
|
||||||
|
|
||||||
err := writeWarnings(r, w)
|
err := writeWarnings(r, wOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -474,7 +528,7 @@ func writeOut(r *featurebase.WireQueryResponse, w io.Writer) error {
|
||||||
lifeAffirmingMessage = " (Sorry! That took longer than expected 😭)"
|
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 {
|
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 errors.Wrapf(err, "writing execution time: %s", r.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -488,81 +542,3 @@ func schemaToRow(schema featurebase.WireQuerySchema) []interface{} {
|
||||||
}
|
}
|
||||||
return ret
|
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
|
|
||||||
}
|
|
||||||
199
cli/cli_test.go
Normal file
199
cli/cli_test.go
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
featurebase "github.com/molecula/featurebase/v3"
|
||||||
|
"github.com/molecula/featurebase/v3/cli"
|
||||||
|
"github.com/molecula/featurebase/v3/logger"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCLI(t *testing.T) {
|
||||||
|
t.Run("Input", func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
capture := newCapture(t)
|
||||||
|
|
||||||
|
cli := cli.NewCLICommand(logger.StderrLogger)
|
||||||
|
cli.Stdin = capture
|
||||||
|
cli.Stdout = capture
|
||||||
|
cli.Queryer = capture
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
assert.NoError(t, cli.Run(ctx))
|
||||||
|
}()
|
||||||
|
|
||||||
|
none := []string{}
|
||||||
|
|
||||||
|
// One statement, one line.
|
||||||
|
capture.Assert("one;", []string{`one`})
|
||||||
|
|
||||||
|
// One statement, multiple lines.
|
||||||
|
capture.Assert("one", none)
|
||||||
|
capture.Assert(" two ", none)
|
||||||
|
capture.Assert("three;", []string{`one
|
||||||
|
two
|
||||||
|
three`})
|
||||||
|
|
||||||
|
// Multiple statements, one line.
|
||||||
|
capture.Assert("foo; bar;", []string{`foo`, `bar`})
|
||||||
|
|
||||||
|
// Multiple statements, multiple lines.
|
||||||
|
capture.Assert("a1", none)
|
||||||
|
capture.Assert("a2; b1", []string{`a1
|
||||||
|
a2`})
|
||||||
|
capture.Assert("b2;", []string{`b1
|
||||||
|
b2`})
|
||||||
|
|
||||||
|
// Blank lines.
|
||||||
|
capture.Assert("one", none)
|
||||||
|
capture.Assert("", none)
|
||||||
|
capture.Assert("three;", []string{`one
|
||||||
|
|
||||||
|
three`})
|
||||||
|
|
||||||
|
// Just a semi-colon.
|
||||||
|
capture.Assert(";", none)
|
||||||
|
|
||||||
|
// Multi-line with just a semi-colon.
|
||||||
|
capture.Assert("one", none)
|
||||||
|
capture.Assert(";", []string{`one`})
|
||||||
|
|
||||||
|
// Ensure a clean exit with no errors.
|
||||||
|
assert.NoError(t, capture.Exit())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Ensure type implementes interface.
|
||||||
|
var _ io.ReadCloser = (*capture)(nil)
|
||||||
|
var _ io.Writer = (*capture)(nil)
|
||||||
|
var _ cli.Queryer = (*capture)(nil)
|
||||||
|
|
||||||
|
// capture implements the various CLI interfaces in order to capture test input
|
||||||
|
// and submit it as though that input were being read from the command line. It
|
||||||
|
// also captures calls made to the Queryer.Query method and ensures the sql the
|
||||||
|
// contain is expected.
|
||||||
|
type capture struct {
|
||||||
|
t *testing.T
|
||||||
|
|
||||||
|
// ch is a channel of strings (one line at a time) of CLI input.
|
||||||
|
ch chan string
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
sqls []string
|
||||||
|
|
||||||
|
// queryDone will receive an event any time the Query method is called and
|
||||||
|
// has completed. This is to tell the Assert method that it's safe to
|
||||||
|
// compare the sqls slice.
|
||||||
|
queryDone chan struct{}
|
||||||
|
|
||||||
|
asserting chan struct{}
|
||||||
|
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCapture(t *testing.T) *capture {
|
||||||
|
return &capture{
|
||||||
|
t: t,
|
||||||
|
ch: make(chan string),
|
||||||
|
sqls: make([]string, 0),
|
||||||
|
queryDone: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *capture) Exit() error {
|
||||||
|
c.sendLine("exit")
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *capture) Assert(in string, out []string) {
|
||||||
|
c.asserting = make(chan struct{})
|
||||||
|
|
||||||
|
c.sendLine(in)
|
||||||
|
|
||||||
|
// Wait for the CLI command to complete processing the input and send the
|
||||||
|
// sql to Query() by blocking on the queryDone channel. Because Query gets
|
||||||
|
// called for every sql statement in the input, an input resulting in
|
||||||
|
// multiple sql statements needs to wait for all expected queries to
|
||||||
|
// complete. A timeout is included to this so it doesn't deadlock in the
|
||||||
|
// case where Query is expected to be called, but isn't; after the timeout,
|
||||||
|
// the test should fail completely. In summary: we wait on queryDone the
|
||||||
|
// number of sql statements we expect. If we receive fewer than expected,
|
||||||
|
// the timeout will occur. If we receive more than expected, the Query()
|
||||||
|
// method will effectively deadlock, reach its own timout, then write to
|
||||||
|
// capture.err, which will be reported upon Exit().
|
||||||
|
for range out {
|
||||||
|
select {
|
||||||
|
case <-c.queryDone:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
c.t.Fatalf("expected Query() to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(c.asserting)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
assert.Equal(c.t, out, c.sqls)
|
||||||
|
|
||||||
|
// Reset the slice.
|
||||||
|
c.sqls = c.sqls[:0]
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendLine sends the given string as a line input to the CLI command. It
|
||||||
|
// appends a line feed to the end of string in order to mimic the user hitting
|
||||||
|
// the return key.
|
||||||
|
func (c *capture) sendLine(s string) {
|
||||||
|
// Add a line feed before putting s on the channel in order to mimic the
|
||||||
|
// user hitting the return key.
|
||||||
|
c.ch <- s + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read is read by the CLI in place of user input. It effectively sends lines of
|
||||||
|
// input to the CLI, getting each line to be sent off the channel.
|
||||||
|
func (c *capture) Read(b []byte) (n int, err error) {
|
||||||
|
s := <-c.ch
|
||||||
|
return strings.NewReader(s).Read(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *capture) Close() error {
|
||||||
|
close(c.ch)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write is called with anything written to output. This would included results
|
||||||
|
// from calling Query() under normal, non-testing conditions, as well as other
|
||||||
|
// informational text sent to output, such as the splash message.
|
||||||
|
func (c *capture) Write(b []byte) (n int, err error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query is called by the CLI command once a full SQL statement is received
|
||||||
|
// (signified by the terminator: `;`).
|
||||||
|
func (c *capture) Query(org, db, sql string) (*featurebase.WireQueryResponse, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.sqls = append(c.sqls, sql)
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case c.queryDone <- struct{}{}:
|
||||||
|
case <-c.asserting:
|
||||||
|
c.mu.Lock()
|
||||||
|
c.err = errors.Errorf("unexpected query: %s", sql)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
return &featurebase.WireQueryResponse{}, nil
|
||||||
|
}
|
||||||
96
cli/queryer.go
Normal file
96
cli/queryer.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
featurebase "github.com/molecula/featurebase/v3"
|
||||||
|
"github.com/molecula/featurebase/v3/dax"
|
||||||
|
queryerhttp "github.com/molecula/featurebase/v3/dax/queryer/http"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Queryer interface {
|
||||||
|
Query(org, db, sql string) (*featurebase.WireQueryResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure type implements interface.
|
||||||
|
var _ Queryer = (*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 _ Queryer = (*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
|
||||||
|
}
|
||||||
30
cmd/cli.go
30
cmd/cli.go
|
|
@ -7,29 +7,29 @@ import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var cli *ctl.CLICommand
|
var cliCmd *cli.CLICommand
|
||||||
|
|
||||||
// newCLICommand runs the FeatureBase CLI subcommand for ingesting bulk data.
|
// newCLICommand runs the FeatureBase CLI subcommand for ingesting bulk data.
|
||||||
func newCLICommand(logdest logger.Logger) *cobra.Command {
|
func newCLICommand(logdest logger.Logger) *cobra.Command {
|
||||||
cli = ctl.NewCLICommand(logdest)
|
cliCmd = cli.NewCLICommand(logdest)
|
||||||
cliCmd := &cobra.Command{
|
cobraCmd := &cobra.Command{
|
||||||
Use: "cli",
|
Use: "cli",
|
||||||
Short: "Query FB with SQL3 from the command line",
|
Short: "Query FB with SQL3 from the command line",
|
||||||
Long: ``,
|
Long: ``,
|
||||||
RunE: usageErrorWrapper(cli),
|
RunE: usageErrorWrapper(cliCmd),
|
||||||
}
|
}
|
||||||
|
|
||||||
flags := cliCmd.Flags()
|
flags := cobraCmd.Flags()
|
||||||
flags.StringVarP(&cli.Host, "host", "", cli.Host, "hostname of FeatureBase.")
|
flags.StringVarP(&cliCmd.Host, "host", "", cliCmd.Host, "hostname of FeatureBase.")
|
||||||
flags.StringVarP(&cli.Port, "port", "", cli.Port, "port of FeatureBase.")
|
flags.StringVarP(&cliCmd.Port, "port", "", cliCmd.Port, "port of FeatureBase.")
|
||||||
flags.StringVar(&cli.HistoryPath, "history-path", cli.HistoryPath, "path for history files.")
|
flags.StringVar(&cliCmd.HistoryPath, "history-path", cliCmd.HistoryPath, "path for history files.")
|
||||||
flags.StringVar(&cli.OrganizationID, "org-id", cli.OrganizationID, "OrganizationID.")
|
flags.StringVar(&cliCmd.OrganizationID, "org-id", cliCmd.OrganizationID, "OrganizationID.")
|
||||||
flags.StringVar(&cli.DatabaseID, "db-id", cli.DatabaseID, "DatabaseID.")
|
flags.StringVar(&cliCmd.DatabaseID, "db-id", cliCmd.DatabaseID, "DatabaseID.")
|
||||||
|
|
||||||
flags.StringVar(&cli.ClientID, "client-id", cli.ClientID, "Cognito Client ID for FeatureBase Cloud access.")
|
flags.StringVar(&cliCmd.ClientID, "client-id", cliCmd.ClientID, "Cognito Client ID for FeatureBase Cloud access.")
|
||||||
flags.StringVar(&cli.Region, "region", cli.Region, "Cloud region for FeatureBase Cloud access (e.g. us-east-2).")
|
flags.StringVar(&cliCmd.Region, "region", cliCmd.Region, "Cloud region for FeatureBase Cloud access (e.g. us-east-2).")
|
||||||
flags.StringVar(&cli.Email, "email", cli.Email, "Email address for FeatureBase Cloud access.")
|
flags.StringVar(&cliCmd.Email, "email", cliCmd.Email, "Email address for FeatureBase Cloud access.")
|
||||||
flags.StringVar(&cli.Password, "password", cli.Password, "Password for FeatureBase Cloud access.")
|
flags.StringVar(&cliCmd.Password, "password", cliCmd.Password, "Password for FeatureBase Cloud access.")
|
||||||
|
|
||||||
return cliCmd
|
return cobraCmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue