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.
This commit is contained in:
parent
b6e642338a
commit
cf72bfa16f
6 changed files with 409 additions and 140 deletions
|
|
@ -1,9 +1,7 @@
|
|||
package ctl
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -16,9 +14,7 @@ import (
|
|||
"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/cli/fbcloud"
|
||||
"github.com/molecula/featurebase/v3/logger"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
|
@ -32,6 +28,12 @@ const (
|
|||
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.
|
||||
|
|
@ -55,35 +57,52 @@ type CLICommand struct {
|
|||
OrganizationID string `json:"org-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 {
|
||||
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 := ""
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
fmt.Printf("Error getting home directory, command history persistence will be disabled: %v\n", err)
|
||||
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, 0750)
|
||||
err := os.MkdirAll(historyDir, 0o750)
|
||||
if err != nil {
|
||||
fmt.Printf("Creating directory for history: %v\n", err)
|
||||
cmd.Printf("Creating directory for history: %v\n", err)
|
||||
} else {
|
||||
historyPath = filepath.Join(historyDir, "cli_history")
|
||||
}
|
||||
}
|
||||
return &CLICommand{
|
||||
Host: defaultHost,
|
||||
HistoryPath: historyPath,
|
||||
|
||||
OrganizationID: "",
|
||||
DatabaseID: "",
|
||||
}
|
||||
cmd.HistoryPath = historyPath
|
||||
}
|
||||
|
||||
// printQualifiers displays the currently set OrganizationID and DatabaseID.
|
||||
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),
|
||||
cmd.OrganizationID,
|
||||
cmd.DatabaseID,
|
||||
|
|
@ -91,6 +110,12 @@ func (cmd *CLICommand) printQualifiers() {
|
|||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
|
@ -106,20 +131,20 @@ func (cmd *CLICommand) setupClient() error {
|
|||
|
||||
switch typ {
|
||||
case featurebaseTypeStandard:
|
||||
fmt.Println("Detected standard deployment")
|
||||
cmd.queryer = &standardQueryer{
|
||||
cmd.Printf("Detected standard deployment\n")
|
||||
cmd.Queryer = &standardQueryer{
|
||||
Host: cmd.Host,
|
||||
Port: cmd.Port,
|
||||
}
|
||||
case featurebaseTypeDAX:
|
||||
fmt.Println("Detected dax deployment")
|
||||
cmd.queryer = &daxQueryer{
|
||||
cmd.Printf("Detected dax deployment\n")
|
||||
cmd.Queryer = &daxQueryer{
|
||||
Host: cmd.Host,
|
||||
Port: cmd.Port,
|
||||
}
|
||||
case featurebaseTypeCloud:
|
||||
fmt.Println("Detected cloud deployment")
|
||||
cmd.queryer = &fbcloud.Queryer{
|
||||
cmd.Printf("Detected cloud deployment\n")
|
||||
cmd.Queryer = &fbcloud.Queryer{
|
||||
Host: hostPort(cmd.Host, cmd.Port),
|
||||
|
||||
ClientID: cmd.ClientID,
|
||||
|
|
@ -222,9 +247,9 @@ func (cmd *CLICommand) detectFBType() (featurebaseType, error) {
|
|||
|
||||
func (cmd *CLICommand) Run(ctx context.Context) error {
|
||||
// Print the splash message.
|
||||
fmt.Print(splash)
|
||||
err := cmd.setupClient()
|
||||
if err != nil {
|
||||
cmd.Printf(splash)
|
||||
cmd.setupHistory()
|
||||
if err := cmd.setupClient(); err != nil {
|
||||
return errors.Wrap(err, "setting up client")
|
||||
}
|
||||
cmd.printQualifiers()
|
||||
|
|
@ -234,6 +259,10 @@ func (cmd *CLICommand) Run(ctx context.Context) error {
|
|||
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")
|
||||
|
|
@ -254,7 +283,7 @@ func (cmd *CLICommand) Run(ctx context.Context) error {
|
|||
} else {
|
||||
rl.SetPrompt(promptBegin)
|
||||
// Add some white space before each new prompt.
|
||||
fmt.Println()
|
||||
cmd.Printf("\n")
|
||||
}
|
||||
|
||||
// Read user provided input.
|
||||
|
|
@ -263,6 +292,27 @@ func (cmd *CLICommand) Run(ctx context.Context) error {
|
|||
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 {
|
||||
|
|
@ -284,7 +334,7 @@ func (cmd *CLICommand) Run(ctx context.Context) error {
|
|||
|
||||
for i, part := range parts {
|
||||
partIsFinal := i == len(parts)-1
|
||||
partIsBlank := part == ""
|
||||
partIsBlank := strings.TrimSpace(part) == ""
|
||||
|
||||
if partIsBlank && partIsFinal {
|
||||
continue
|
||||
|
|
@ -315,7 +365,7 @@ func (cmd *CLICommand) Run(ctx context.Context) error {
|
|||
|
||||
err = rl.SaveHistory(strings.Join(cmd.commands, "; ") + ";")
|
||||
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 {
|
||||
|
|
@ -330,14 +380,10 @@ func appendCommand(orig string, part string) string {
|
|||
if orig == "" {
|
||||
return part
|
||||
} 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 {
|
||||
// Clear out the buffered commands on any exit from this method.
|
||||
defer func() {
|
||||
|
|
@ -352,12 +398,12 @@ func (cmd *CLICommand) executeCommands(ctx context.Context) error {
|
|||
continue
|
||||
}
|
||||
|
||||
sqlResponse, err := cmd.queryer.Query(cmd.OrganizationID, cmd.DatabaseID, sql)
|
||||
sqlResponse, err := cmd.Queryer.Query(cmd.OrganizationID, cmd.DatabaseID, sql)
|
||||
if err != nil {
|
||||
fmt.Printf("making query: %v\n", err)
|
||||
cmd.Printf("making query: %v\n", err)
|
||||
continue
|
||||
}
|
||||
err = writeOut(sqlResponse, os.Stdout)
|
||||
err = writeOut(sqlResponse, cmd.Stdout, cmd.Stderr)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "writing out response")
|
||||
}
|
||||
|
|
@ -366,6 +412,12 @@ func (cmd *CLICommand) executeCommands(ctx context.Context) error {
|
|||
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;
|
||||
|
|
@ -431,19 +483,19 @@ func writeWarnings(r *featurebase.WireQueryResponse, w io.Writer) error {
|
|||
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 {
|
||||
return errors.New("attempt to write out nil response")
|
||||
}
|
||||
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 writeWarnings(r, w)
|
||||
return writeWarnings(r, wOut)
|
||||
}
|
||||
|
||||
t := table.NewWriter()
|
||||
t.SetOutputMirror(w)
|
||||
t.SetOutputMirror(wOut)
|
||||
|
||||
// Don't uppercase the header values.
|
||||
t.Style().Format.Header = text.FormatDefault
|
||||
|
|
@ -461,7 +513,7 @@ func writeOut(r *featurebase.WireQueryResponse, w io.Writer) error {
|
|||
}
|
||||
t.Render()
|
||||
|
||||
err := writeWarnings(r, w)
|
||||
err := writeWarnings(r, wOut)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -474,7 +526,7 @@ func writeOut(r *featurebase.WireQueryResponse, w io.Writer) error {
|
|||
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)
|
||||
}
|
||||
|
||||
|
|
@ -488,81 +540,3 @@ func schemaToRow(schema featurebase.WireQuerySchema) []interface{} {
|
|||
}
|
||||
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
|
||||
}
|
||||
32
cmd/cli.go
32
cmd/cli.go
|
|
@ -2,34 +2,34 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/molecula/featurebase/v3/ctl"
|
||||
"github.com/molecula/featurebase/v3/cli"
|
||||
"github.com/molecula/featurebase/v3/logger"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var cli *ctl.CLICommand
|
||||
var cliCmd *cli.CLICommand
|
||||
|
||||
// newCLICommand runs the FeatureBase CLI subcommand for ingesting bulk data.
|
||||
func newCLICommand(logdest logger.Logger) *cobra.Command {
|
||||
cli = ctl.NewCLICommand(logdest)
|
||||
cliCmd := &cobra.Command{
|
||||
cliCmd = cli.NewCLICommand(logdest)
|
||||
cobraCmd := &cobra.Command{
|
||||
Use: "cli",
|
||||
Short: "Query FB with SQL3 from the command line",
|
||||
Long: ``,
|
||||
RunE: usageErrorWrapper(cli),
|
||||
RunE: usageErrorWrapper(cliCmd),
|
||||
}
|
||||
|
||||
flags := cliCmd.Flags()
|
||||
flags.StringVarP(&cli.Host, "host", "", cli.Host, "hostname of FeatureBase.")
|
||||
flags.StringVarP(&cli.Port, "port", "", cli.Port, "port of FeatureBase.")
|
||||
flags.StringVar(&cli.HistoryPath, "history-path", cli.HistoryPath, "path for history files.")
|
||||
flags.StringVar(&cli.OrganizationID, "org-id", cli.OrganizationID, "OrganizationID.")
|
||||
flags.StringVar(&cli.DatabaseID, "db-id", cli.DatabaseID, "DatabaseID.")
|
||||
flags := cobraCmd.Flags()
|
||||
flags.StringVarP(&cliCmd.Host, "host", "", cliCmd.Host, "hostname of FeatureBase.")
|
||||
flags.StringVarP(&cliCmd.Port, "port", "", cliCmd.Port, "port of FeatureBase.")
|
||||
flags.StringVar(&cliCmd.HistoryPath, "history-path", cliCmd.HistoryPath, "path for history files.")
|
||||
flags.StringVar(&cliCmd.OrganizationID, "org-id", cliCmd.OrganizationID, "OrganizationID.")
|
||||
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(&cli.Region, "region", cli.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(&cli.Password, "password", cli.Password, "Password for FeatureBase Cloud access.")
|
||||
flags.StringVar(&cliCmd.ClientID, "client-id", cliCmd.ClientID, "Cognito Client ID for FeatureBase Cloud access.")
|
||||
flags.StringVar(&cliCmd.Region, "region", cliCmd.Region, "Cloud region for FeatureBase Cloud access (e.g. us-east-2).")
|
||||
flags.StringVar(&cliCmd.Email, "email", cliCmd.Email, "Email address 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