From 393721c0ce555a1444d051d2dfdb1ff61bb56f9e Mon Sep 17 00:00:00 2001 From: Travis Turner Date: Thu, 16 Feb 2023 20:41:39 -0600 Subject: [PATCH] Add viper (for env variable) support to CLI (#2251) * Add viper (for env variable) support to CLI * Move the "featurebase cli" sub-command to its own "fbsql" command I don't know if this is the final name, but putting it here as a placeholder for now. * Handle single `--command` flags. This also adds a printer interface so we can opt NOT to print setup information in non-interactive mode. * Add support for multiple `--command` flags in the same call * Add support for multiple `--file` flags * Move members related to Config into a separate struct * Make sure non-interactive mode can connect to a database * comment fix * support control-C on readline * Prevent connection message from printing in non-interactive mode * Return errors (instead of printing them) in non-interactive mode --- Makefile | 5 +- cli/cli.go | 326 +++++++++++++++++++++++++++++++++++---------- cli/cli_test.go | 2 +- cli/config.go | 22 +++ cli/meta.go | 68 +++++----- cli/writer.go | 2 +- cli/writer_test.go | 19 +++ cmd/cli.go | 34 ++--- cmd/fbsql/main.go | 13 ++ cmd/root.go | 19 +-- ctl/cli.go | 39 ++++++ 11 files changed, 414 insertions(+), 135 deletions(-) create mode 100644 cli/config.go create mode 100644 cmd/fbsql/main.go create mode 100644 ctl/cli.go diff --git a/Makefile b/Makefile index 2f76cb660..2e952d166 100644 --- a/Makefile +++ b/Makefile @@ -144,7 +144,7 @@ authclustertests: vendor CLUSTERTESTS_FB_ARGS=$(AUTH_ARGS) $(DOCKER_COMPOSE) -f internal/clustertests/docker-compose.yml down # Install FeatureBase and IDK -install: install-featurebase install-idk +install: install-featurebase install-idk install-fbsql install-featurebase: $(GO) install -tags='$(BUILD_TAGS)' -ldflags $(LDFLAGS) $(FLAGS) ./cmd/featurebase @@ -152,6 +152,9 @@ install-featurebase: install-idk: $(MAKE) -C ./idk install +install-fbsql: + $(GO) install ./cmd/fbsql + # Build the lattice assets build-lattice: docker build -t lattice:build ./lattice diff --git a/cli/cli.go b/cli/cli.go index 946965c71..7a3ee1606 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -14,8 +14,8 @@ import ( "github.com/chzyer/readline" featurebase "github.com/featurebasedb/featurebase/v3" "github.com/featurebasedb/featurebase/v3/cli/fbcloud" + "github.com/featurebasedb/featurebase/v3/errors" "github.com/featurebasedb/featurebase/v3/logger" - "github.com/pkg/errors" ) const ( @@ -38,23 +38,19 @@ Type "\q" to quit. `, featurebase.Version) ) -type CLICommand struct { - Host string `json:"host"` - Port string `json:"port"` - HistoryPath string `json:"history-path"` +// Ensure type implments interfaces. +var _ printer = (*Command)(nil) - // Cloud Auth - ClientID string `json:"client-id"` - Region string `json:"region"` - Email string `json:"email"` - Password string `json:"password"` +type Command struct { + host string + port string splitter *splitter buffer *buffer workingDir *workingDir - OrganizationID string `json:"org-id"` - Database string `json:"db"` + organizationID string + database string databaseID string databaseName string @@ -69,17 +65,47 @@ type CLICommand struct { output io.Writer `json:"-"` writeOptions *writeOptions + Config *Config `json:"config"` + + historyPath string + + // Commands contains optional commands provided via one or more `-c` (or + // `--command`) flags. If this is non-empty, the cli will run in + // non-interactive mode; i.e. it will quit after the command is complete. + Commands []string `json:"commands"` + + // Files contains optional files provided via one or more `-f` (or `--file`) + // flags. If this is non-empty, the cli will run in non-interactive mode; + // i.e. it will quit after the command is complete. + Files []string `json:"files"` + + // nonInteractiveMode is set to true when fbsql is running in + // non-ineracative mode. And example of this is when the user has provided a + // `-c` flag in the command line. + nonInteractiveMode bool + // quit gets closed when Run should stop listening for input. quit chan struct{} } -func NewCLICommand(logdest logger.Logger) *CLICommand { - return &CLICommand{ - Host: defaultHost, - HistoryPath: "", +func NewCommand(logdest logger.Logger) *Command { + return &Command{ + Config: &Config{ + Host: defaultHost, + Port: "", - OrganizationID: "", - Database: "", + OrganizationID: "", + Database: "", + + CloudAuth: CloudAuthConfig{ + ClientID: "", + Region: "", + Email: "", + Password: "", + }, + + HistoryPath: "", + }, splitter: newSplitter(), buffer: newBuffer(), @@ -96,9 +122,40 @@ func NewCLICommand(logdest logger.Logger) *CLICommand { } } -// Run is the main entry-point to the CLI. Currently it handles the interaction -// with a user, as opposed to calling `featurebase cli` in a script. -func (cmd *CLICommand) Run(ctx context.Context) error { +// Run is the main entry-point to the CLI. +func (cmd *Command) Run(ctx context.Context) error { + cmd.setupConfig() + + // Check to see if Command needs to run in non-interactive mode. + if len(cmd.Commands) > 0 || len(cmd.Files) > 0 { + cmd.nonInteractiveMode = true + + if err := cmd.setupClient(); err != nil { + return errors.Wrap(err, "setting up client") + } + if err := cmd.connectToDatabase(cmd.database); err != nil { + cmd.Errorf(errors.Wrap(err, "connecting to database").Error() + "\n") + } + + // Run Commands. + for _, line := range cmd.Commands { + if err := cmd.handleLine(line); err != nil { + cmd.Errorf(err.Error()) + return nil + } + } + + // Run Files. + for _, fname := range cmd.Files { + if _, err := executeFile(cmd, fname); err != nil { + cmd.Errorf(err.Error()) + return nil + } + } + + return nil + } + // Print the splash message. cmd.Printf(splash) cmd.setupHistory() @@ -106,13 +163,13 @@ func (cmd *CLICommand) Run(ctx context.Context) error { return errors.Wrap(err, "setting up client") } cmd.printConnInfo() - if err := cmd.connectToDatabase(cmd.Database); err != nil { + if err := cmd.connectToDatabase(cmd.database); err != nil { cmd.Errorf(errors.Wrap(err, "connecting to database").Error() + "\n") } rl, err := readline.NewEx(&readline.Config{ Prompt: promptBegin, - HistoryFile: cmd.HistoryPath, + HistoryFile: cmd.historyPath, HistoryLimit: 100000, DisableAutoSaveHistory: true, @@ -138,7 +195,11 @@ func (cmd *CLICommand) Run(ctx context.Context) error { // Read user provided input. line, err := rl.Readline() - if err != nil { + if err == readline.ErrInterrupt { + inMidCommand = false + cmd.buffer.reset() + continue + } else if err != nil { return errors.Wrap(err, "reading line") } @@ -236,11 +297,27 @@ func (cmd *CLICommand) Run(ctx context.Context) error { // close is called upon quitting. It should close any remaining open file // handles used by the CLICommand. -func (cmd *CLICommand) close() error { +func (cmd *Command) close() error { return cmd.closeOutput() } -func (cmd *CLICommand) executeAndWriteQuery(qry query) error { +// setupConfig sets up private struct members based on values provided via the +// configuration flags. +func (cmd *Command) setupConfig() { + if cmd.Config == nil { + return + } + + cmd.host = cmd.Config.Host + cmd.port = cmd.Config.Port + + cmd.organizationID = cmd.Config.OrganizationID + cmd.database = cmd.Config.Database + + cmd.historyPath = cmd.Config.HistoryPath +} + +func (cmd *Command) executeAndWriteQuery(qry query) error { queryResponse, err := cmd.executeQuery(qry) if err != nil { return errors.Wrap(err, "making query") @@ -253,32 +330,63 @@ func (cmd *CLICommand) executeAndWriteQuery(qry query) error { return nil } -func (cmd *CLICommand) executeQuery(qry query) (*featurebase.WireQueryResponse, error) { - return cmd.Queryer.Query(cmd.OrganizationID, cmd.databaseID, qry.Reader()) +func (cmd *Command) executeQuery(qry query) (*featurebase.WireQueryResponse, error) { + wqr, err := cmd.Queryer.Query(cmd.organizationID, cmd.databaseID, qry.Reader()) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + + // If we're running in non-interactive mode, we need to check the error that + // comes back in the WireQueryResponse. If there's an error, we want to + // return it now (rather than just printing it later) so that we immediately + // stop any further execution of commands. + if cmd.nonInteractiveMode && wqr.Error != "" { + return nil, errors.Errorf(wqr.Error) + } + + return wqr, nil } +// printer is an interface which encapsulates the methods used to print output +// to the various io.Writers. +type printer interface { + Printf(format string, a ...any) + Outputf(format string, a ...any) + Errorf(format string, a ...any) +} + +type nopPrinter struct{} + +func newNopPrinter() *nopPrinter { + return &nopPrinter{} +} + +func (n *nopPrinter) Printf(format string, a ...any) {} +func (n *nopPrinter) Outputf(format string, a ...any) {} +func (n *nopPrinter) Errorf(format string, a ...any) {} + // Printf is a helper method which sends the given payload to stdout. -func (cmd *CLICommand) Printf(format string, a ...any) { +func (cmd *Command) Printf(format string, a ...any) { out := fmt.Sprintf(format, a...) cmd.Stdout.Write([]byte(out)) } // Outputf is a helper method which sends the given payload to output. -func (cmd *CLICommand) Outputf(format string, a ...any) { +func (cmd *Command) Outputf(format string, a ...any) { out := fmt.Sprintf(format, a...) cmd.output.Write([]byte(out)) } // Errorf is a helper method which sends the given payload to stderr. -func (cmd *CLICommand) Errorf(format string, a ...any) { +func (cmd *Command) Errorf(format string, a ...any) { out := fmt.Sprintf(format, a...) cmd.Stderr.Write([]byte(out)) } -func (cmd *CLICommand) setupHistory() { +func (cmd *Command) 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 != "" { + if cmd.historyPath != "" { return } @@ -294,20 +402,25 @@ func (cmd *CLICommand) setupHistory() { historyPath = filepath.Join(historyDir, "cli_history") } } - cmd.HistoryPath = historyPath + cmd.historyPath = historyPath } // printConnInfo displays the currently set host. // TODO(tlt): extend this to be the output of the /conninfo meta-command. -func (cmd *CLICommand) printConnInfo() { - cmd.Printf("Host: %s\n", hostPort(cmd.Host, cmd.Port)) +func (cmd *Command) printConnInfo() { + cmd.Printf("Host: %s\n", hostPort(cmd.host, cmd.port)) } -func (cmd *CLICommand) connectToDatabase(dbName string) error { +func (cmd *Command) connectToDatabase(dbName string) error { + var p printer = cmd + if cmd.nonInteractiveMode { + p = newNopPrinter() + } + if dbName == "" { cmd.databaseID = "" cmd.databaseName = "" - cmd.Printf(cmd.connectionMessage()) + p.Printf(cmd.connectionMessage()) return nil } @@ -327,40 +440,45 @@ func (cmd *CLICommand) connectToDatabase(dbName string) error { if db[1] == dbName { cmd.databaseName = dbName cmd.databaseID = db[0].(string) - cmd.Printf(cmd.connectionMessage()) + p.Printf(cmd.connectionMessage()) return nil } } return errors.Errorf("invalid database: %s", dbName) } -func (cmd *CLICommand) orgMessage() string { - if cmd.OrganizationID == "" { +func (cmd *Command) orgMessage() string { + if cmd.organizationID == "" { return "You have not set an organization.\n" } - return fmt.Sprintf("You have set organization \"%s\".\n", cmd.OrganizationID) + return fmt.Sprintf("You have set organization \"%s\".\n", cmd.organizationID) } -func (cmd *CLICommand) connectionMessage() string { +func (cmd *Command) connectionMessage() string { if cmd.databaseName == "" { return "You are not connected to a database.\n" } return fmt.Sprintf("You are now connected to database \"%s\" (%s) as user \"???\".\n", cmd.databaseName, cmd.databaseID) } -func (cmd *CLICommand) setupClient() error { +func (cmd *Command) 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) == "" { + var p printer = cmd + if cmd.nonInteractiveMode { + p = newNopPrinter() + } + + if strings.TrimSpace(cmd.host) == "" { return errors.Errorf("no host provided\n") } - if !strings.HasPrefix(cmd.Host, "http") { - cmd.Host = "http://" + cmd.Host + if !strings.HasPrefix(cmd.host, "http") { + cmd.host = "http://" + cmd.host } typ, err := cmd.detectFBType() @@ -370,39 +488,40 @@ func (cmd *CLICommand) setupClient() error { switch typ { case featurebaseTypeOnPremClassic: - cmd.Printf("Detected on-prem, classic deployment.\n") + p.Printf("Detected on-prem, classic deployment.\n") cmd.Queryer = &standardQueryer{ - Host: cmd.Host, - Port: cmd.Port, + Host: cmd.host, + Port: cmd.port, } case featurebaseTypeOnPremServerless: - cmd.Printf("Detected on-prem, serverless deployment.\n") + p.Printf("Detected on-prem, serverless deployment.\n") cmd.Queryer = &serverlessQueryer{ - Host: cmd.Host, - Port: cmd.Port, + Host: cmd.host, + Port: cmd.port, } case featurebaseTypeCloud: - cmd.Printf("Detected cloud deployment.\n") - cmd.Queryer = &fbcloud.Queryer{ - Host: hostPort(cmd.Host, cmd.Port), + p.Printf("Detected cloud deployment.\n") - ClientID: cmd.ClientID, - Region: cmd.Region, - Email: cmd.Email, - Password: cmd.Password, + cmd.Queryer = &fbcloud.Queryer{ + Host: hostPort(cmd.host, cmd.port), + + ClientID: cmd.Config.CloudAuth.ClientID, + Region: cmd.Config.CloudAuth.Region, + Email: cmd.Config.CloudAuth.Email, + Password: cmd.Config.CloudAuth.Password, } case featurebaseTypeUnknown: - cmd.Printf("Could not detect deployment\n") + p.Printf("Could not detect deployment\n") // cmd.Queryer = &nopQueryer{} // Instead of using a no-op queryer when the type can't be detected, we // default to using a cloud queryer. cmd.Queryer = &fbcloud.Queryer{ - Host: hostPort(cmd.Host, cmd.Port), + Host: hostPort(cmd.host, cmd.port), - ClientID: cmd.ClientID, - Region: cmd.Region, - Email: cmd.Email, - Password: cmd.Password, + ClientID: cmd.Config.CloudAuth.ClientID, + Region: cmd.Config.CloudAuth.Region, + Email: cmd.Config.CloudAuth.Email, + Password: cmd.Config.CloudAuth.Password, } default: return errors.Errorf("unknown type: %s", typ) @@ -428,7 +547,7 @@ func hostPort(host, port string) string { // detectFBType determines if we're talking to standalone FeatureBase // or FeatureBase Cloud -func (cmd *CLICommand) detectFBType() (featurebaseType, error) { +func (cmd *Command) detectFBType() (featurebaseType, error) { type trial struct { port string health string @@ -440,23 +559,23 @@ func (cmd *CLICommand) detectFBType() (featurebaseType, error) { trials := []trial{} var clientTimeout time.Duration - if cmd.Port != "" { + if cmd.port != "" { clientTimeout = 100 * time.Millisecond trials = append(trials, // on-prem, serverless trial{ - port: cmd.Port, + port: cmd.port, health: "/queryer/health", typ: featurebaseTypeOnPremServerless, }, // on-prem, classic trial{ - port: cmd.Port, + port: cmd.port, health: "/status", typ: featurebaseTypeOnPremClassic, }, ) - } else if strings.HasPrefix(cmd.Host, "https") { + } else if strings.HasPrefix(cmd.host, "https") { // https suggesting we might be connecting to a cloud host clientTimeout = 1 * time.Second trials = append(trials, @@ -490,11 +609,11 @@ func (cmd *CLICommand) detectFBType() (featurebaseType, error) { Timeout: clientTimeout, } for _, trial := range trials { - url := hostPort(cmd.Host, trial.port) + trial.health + 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 + cmd.port = trial.port return trial.typ, nil } } @@ -502,7 +621,7 @@ func (cmd *CLICommand) detectFBType() (featurebaseType, error) { return featurebaseTypeUnknown, nil } -func (cmd *CLICommand) closeOutput() error { +func (cmd *Command) closeOutput() error { if cmd.output == nil { return nil } @@ -513,3 +632,62 @@ func (cmd *CLICommand) closeOutput() error { return nil } + +func (cmd *Command) handleLine(line string) error { + // For single-line command handling, we handle either a meta-command, or + // query parts, but not both. The logic is that any line which begins with + // "\" will be handled as a meta-command, otherwise it will be handled as a + // query. + if len(line) == 0 { + return nil + } else if line[0] == byte('\\') { + return cmd.handleLineAsMetaCommand(line) + } else { + return cmd.handleLineAsQueryParts(line) + } +} + +func (cmd *Command) handleLineAsMetaCommand(line string) error { + _, mcs, err := cmd.splitter.split(line) + if err != nil { + return errors.Wrapf(err, "splitting line") + } + + for i := range mcs { + _, err := mcs[i].execute(cmd) + if err != nil { + return errors.Wrap(err, "executing meta command") + } + } + + return nil +} + +func (cmd *Command) handleLineAsQueryParts(line string) error { + qps, mcs, err := cmd.splitter.split(line) + if err != nil { + return errors.Wrapf(err, "splitting line") + } else if len(mcs) > 0 { + return errors.Errorf("--command does not support meta-commands") + } + + // Add a termintor part to the end of []queryPart. We do this because the + // command is coming in from the --command flag, it may not end with a + // semi-colon, but we still want to execute it. + if len(qps) > 0 { + if _, ok := qps[len(qps)-1].(*partTerminator); !ok { + qps = append(qps, newPartTerminator()) + } + } + + for i := range qps { + if qry, err := cmd.buffer.addPart(qps[i]); err != nil { + return errors.Wrap(err, "adding part to buffer") + } else if qry != nil { + if err := cmd.executeAndWriteQuery(qry); err != nil { + return errors.Wrap(err, "executing query") + } + } + } + return nil +} diff --git a/cli/cli_test.go b/cli/cli_test.go index 9a5ee576f..bf02013d5 100644 --- a/cli/cli_test.go +++ b/cli/cli_test.go @@ -21,7 +21,7 @@ func TestCLI(t *testing.T) { capture := newCapture(t) - cli := cli.NewCLICommand(logger.StderrLogger) + cli := cli.NewCommand(logger.StderrLogger) cli.Stdin = capture cli.Stdout = capture cli.Queryer = capture diff --git a/cli/config.go b/cli/config.go new file mode 100644 index 000000000..3953388c1 --- /dev/null +++ b/cli/config.go @@ -0,0 +1,22 @@ +package cli + +// Config represents the configuration for the command. +type Config struct { + Host string `json:"host"` + Port string `json:"port"` + + OrganizationID string `json:"org-id"` + Database string `json:"db"` + + // CloudAuth + CloudAuth CloudAuthConfig `json:"cloud-auth"` + + HistoryPath string `json:"history-path"` +} + +type CloudAuthConfig struct { + ClientID string `json:"client-id"` + Region string `json:"region"` + Email string `json:"email"` + Password string `json:"password"` +} diff --git a/cli/meta.go b/cli/meta.go index f4ce8f302..b249691fd 100644 --- a/cli/meta.go +++ b/cli/meta.go @@ -29,7 +29,7 @@ const ( // metaCommand is the interface for any type responding to a "\" meta-command. type metaCommand interface { - execute(cmd *CLICommand) (action, error) + execute(cmd *Command) (action, error) } // Ensure type implements interface. @@ -71,7 +71,7 @@ func newMetaBang(args []string) *metaBang { } } -func (m *metaBang) execute(cmd *CLICommand) (action, error) { +func (m *metaBang) execute(cmd *Command) (action, error) { if len(m.args) == 0 { return actionNone, errors.Errorf("meta command '!' requires at least one argument") } @@ -95,7 +95,7 @@ func newMetaBorder(args []string) *metaBorder { } } -func (m *metaBorder) execute(cmd *CLICommand) (action, error) { +func (m *metaBorder) execute(cmd *Command) (action, error) { switch len(m.args) { case 0: // pass @@ -129,7 +129,7 @@ func newMetaChangeDirectory(args []string) *metaChangeDirectory { } } -func (m *metaChangeDirectory) execute(cmd *CLICommand) (action, error) { +func (m *metaChangeDirectory) execute(cmd *Command) (action, error) { if len(m.args) != 1 { return actionNone, errors.Errorf("meta command 'cd' requires exactly one argument") } @@ -150,7 +150,7 @@ func newMetaConnect(args []string) *metaConnect { } } -func (m *metaConnect) execute(cmd *CLICommand) (action, error) { +func (m *metaConnect) execute(cmd *Command) (action, error) { switch len(m.args) { case 0: cmd.Printf(cmd.connectionMessage()) @@ -177,7 +177,7 @@ func newMetaEcho(args []string) *metaEcho { } } -func (m *metaEcho) execute(cmd *CLICommand) (action, error) { +func (m *metaEcho) execute(cmd *Command) (action, error) { return echo(m.args, cmd.Stdout) } @@ -218,7 +218,7 @@ func newMetaExpanded(args []string) *metaExpanded { } } -func (m *metaExpanded) execute(cmd *CLICommand) (action, error) { +func (m *metaExpanded) execute(cmd *Command) (action, error) { switch len(m.args) { case 0: cmd.writeOptions.expanded = !cmd.writeOptions.expanded @@ -257,7 +257,7 @@ func newMetaFile(args []string) *metaFile { } } -func (m *metaFile) execute(cmd *CLICommand) (action, error) { +func (m *metaFile) execute(cmd *Command) (action, error) { if len(m.args) != 1 { return actionNone, errors.Errorf("meta command 'file' requires exactly one argument") } @@ -292,7 +292,7 @@ func newMetaHelp(args []string) *metaHelp { } } -func (m *metaHelp) execute(cmd *CLICommand) (action, error) { +func (m *metaHelp) execute(cmd *Command) (action, error) { helpText := `General \q[uit] quit psql \watch [SEC] execute query every SEC seconds @@ -323,7 +323,7 @@ Formatting \pset [NAME [VALUE]] set table output option (border|expanded|tuples_only) \t [on|off] show only rows - \x [on|off|auto] toggle expanded output + \x [on|off] toggle expanded output Connection \c[onnect] [DBNAME] connect to new database @@ -352,14 +352,18 @@ func newMetaInclude(args []string) *metaInclude { } } -func (m *metaInclude) execute(cmd *CLICommand) (action, error) { +func (m *metaInclude) execute(cmd *Command) (action, error) { if len(m.args) != 1 { return actionNone, errors.Errorf("meta command 'include' requires exactly one argument") } - file, err := os.Open(m.args[0]) + return executeFile(cmd, m.args[0]) +} + +func executeFile(cmd *Command, fileName string) (action, error) { + file, err := os.Open(fileName) if err != nil { - return actionNone, errors.Wrapf(err, "opening file: %s", m.args[0]) + return actionNone, errors.Wrapf(err, "opening file: %s", fileName) } defer file.Close() @@ -376,7 +380,7 @@ func (m *metaInclude) execute(cmd *CLICommand) (action, error) { if err != nil { return actionNone, errors.Wrapf(err, "splitting lines") } else if len(mcs) > 0 { - return actionNone, errors.Errorf("include does not support meta-commands") + return actionNone, errors.Errorf("include does not support meta-commands") } for i := range qps { @@ -390,7 +394,7 @@ func (m *metaInclude) execute(cmd *CLICommand) (action, error) { } } if err := sc.Err(); err != nil { - return actionNone, errors.Wrapf(err, "scanning file: %s", m.args[0]) + return actionNone, errors.Wrapf(err, "scanning file: %s", fileName) } return actionReset, nil @@ -405,7 +409,7 @@ func newMetaListDatabases() *metaListDatabases { return &metaListDatabases{} } -func (m *metaListDatabases) execute(cmd *CLICommand) (action, error) { +func (m *metaListDatabases) execute(cmd *Command) (action, error) { qry := []queryPart{ newPartRaw("SHOW DATABASES"), } @@ -426,7 +430,7 @@ func newMetaListTables() *metaListTables { return &metaListTables{} } -func (m *metaListTables) execute(cmd *CLICommand) (action, error) { +func (m *metaListTables) execute(cmd *Command) (action, error) { qry := []queryPart{ newPartRaw("SHOW TABLES"), } @@ -451,14 +455,14 @@ func newMetaOrg(args []string) *metaOrg { } } -func (m *metaOrg) execute(cmd *CLICommand) (action, error) { +func (m *metaOrg) execute(cmd *Command) (action, error) { switch len(m.args) { case 0: cmd.Printf(cmd.orgMessage()) return actionNone, nil case 1: - cmd.OrganizationID = m.args[0] + cmd.organizationID = m.args[0] cmd.Printf(cmd.orgMessage()) return actionNone, nil @@ -480,7 +484,7 @@ func newMetaOutput(args []string) *metaOutput { } } -func (m *metaOutput) execute(cmd *CLICommand) (action, error) { +func (m *metaOutput) execute(cmd *Command) (action, error) { switch len(m.args) { case 0: // Close cmd.output (if closable). @@ -522,7 +526,7 @@ func newMetaPrint() *metaPrint { return &metaPrint{} } -func (m *metaPrint) execute(cmd *CLICommand) (action, error) { +func (m *metaPrint) execute(cmd *Command) (action, error) { cmd.Printf(cmd.buffer.print() + "\n") return actionNone, nil } @@ -540,7 +544,7 @@ func newMetaPSet(args []string) *metaPSet { } } -func (m *metaPSet) print(cmd *CLICommand) { +func (m *metaPSet) print(cmd *Command) { onOff := func(b bool) string { if b { return "on" @@ -561,7 +565,7 @@ tuples_only %s } -func (m *metaPSet) execute(cmd *CLICommand) (action, error) { +func (m *metaPSet) execute(cmd *Command) (action, error) { switch len(m.args) { case 0: m.print(cmd) @@ -598,7 +602,7 @@ func newMetaQEcho(args []string) *metaQEcho { } } -func (m *metaQEcho) execute(cmd *CLICommand) (action, error) { +func (m *metaQEcho) execute(cmd *Command) (action, error) { return echo(m.args, cmd.output) } @@ -611,7 +615,7 @@ func newMetaQuit() *metaQuit { return &metaQuit{} } -func (m *metaQuit) execute(cmd *CLICommand) (action, error) { +func (m *metaQuit) execute(cmd *Command) (action, error) { return actionQuit, nil } @@ -624,7 +628,7 @@ func newMetaReset() *metaReset { return &metaReset{} } -func (m *metaReset) execute(cmd *CLICommand) (action, error) { +func (m *metaReset) execute(cmd *Command) (action, error) { cmd.Printf(cmd.buffer.reset()) return actionReset, nil } @@ -642,7 +646,7 @@ func newMetaSet(args []string) *metaSet { } } -func (m *metaSet) execute(cmd *CLICommand) (action, error) { +func (m *metaSet) execute(cmd *Command) (action, error) { // TODO: set the variable (or clear it, etc) return actionNone, nil } @@ -660,7 +664,7 @@ func newMetaTiming(args []string) *metaTiming { } } -func (m *metaTiming) execute(cmd *CLICommand) (action, error) { +func (m *metaTiming) execute(cmd *Command) (action, error) { switch len(m.args) { case 0: cmd.writeOptions.timing = !cmd.writeOptions.timing @@ -699,7 +703,7 @@ func newMetaTuplesOnly(args []string) *metaTuplesOnly { } } -func (m *metaTuplesOnly) execute(cmd *CLICommand) (action, error) { +func (m *metaTuplesOnly) execute(cmd *Command) (action, error) { switch len(m.args) { case 0: cmd.writeOptions.tuplesOnly = !cmd.writeOptions.tuplesOnly @@ -738,7 +742,7 @@ func newMetaWarn(args []string) *metaWarn { } } -func (m *metaWarn) execute(cmd *CLICommand) (action, error) { +func (m *metaWarn) execute(cmd *Command) (action, error) { return echo(m.args, cmd.Stderr) } @@ -755,7 +759,7 @@ func newMetaWatch(args []string) *metaWatch { } } -func (m *metaWatch) execute(cmd *CLICommand) (action, error) { +func (m *metaWatch) execute(cmd *Command) (action, error) { period := 2 * time.Second qry := cmd.buffer.lastQuery @@ -809,7 +813,7 @@ func newMetaWrite(args []string) *metaWrite { } } -func (m *metaWrite) execute(cmd *CLICommand) (action, error) { +func (m *metaWrite) execute(cmd *Command) (action, error) { switch len(m.args) { case 0: cmd.Errorf(`\w: missing required argument` + "\n") diff --git a/cli/writer.go b/cli/writer.go index 4111643d9..d84e71a75 100644 --- a/cli/writer.go +++ b/cli/writer.go @@ -23,7 +23,7 @@ func defaultWriteOptions() *writeOptions { return &writeOptions{ border: 1, expanded: false, - timing: true, + timing: false, tuplesOnly: false, } } diff --git a/cli/writer_test.go b/cli/writer_test.go index dcb871278..4bfec9627 100644 --- a/cli/writer_test.go +++ b/cli/writer_test.go @@ -50,6 +50,25 @@ func TestWriter(t *testing.T) { " 3 | Cindy | 28 ", "", ), + expOut: "", + expErr: "", + }, + { + // timing on + format: &writeOptions{ + border: 1, + expanded: false, + timing: true, + tuplesOnly: false, + }, + expQOut: stringOfLines( + " _id | name | age ", + "-----+-------+-----", + " 1 | Amy | 44 ", + " 2 | Bob | 32 ", + " 3 | Cindy | 28 ", + "", + ), expOut: "Execution time: 0μs\n", expErr: "", }, diff --git a/cmd/cli.go b/cmd/cli.go index 993089f16..09e7428d4 100644 --- a/cmd/cli.go +++ b/cmd/cli.go @@ -2,34 +2,34 @@ package cmd import ( + "io" + "github.com/featurebasedb/featurebase/v3/cli" + "github.com/featurebasedb/featurebase/v3/ctl" "github.com/featurebasedb/featurebase/v3/logger" "github.com/spf13/cobra" + "github.com/spf13/viper" ) -var cliCmd *cli.CLICommand +var cliCmd *cli.Command -// newCLICommand runs the FeatureBase CLI subcommand for ingesting bulk data. -func newCLICommand(logdest logger.Logger) *cobra.Command { - cliCmd = cli.NewCLICommand(logdest) +// NewCLICommand runs the FeatureBase CLI subcommand. +func NewCLICommand(stderr io.Writer) *cobra.Command { + logdest := logger.NewStandardLogger(stderr) + cliCmd = cli.NewCommand(logdest) cobraCmd := &cobra.Command{ - Use: "cli", + Use: "fbsql", Short: "Query FeatureBase with SQL from the command line", Long: ``, RunE: usageErrorWrapper(cliCmd), + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + v := viper.New() + return setAllConfig(v, cmd.Flags(), "FBSQL") + }, + SilenceErrors: true, } - 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.Database, "db", cliCmd.Database, "Name of the database to connect to.") - - 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.") - + // Attach flags to the command. + ctl.BuildCLIFlags(cobraCmd, cliCmd) return cobraCmd } diff --git a/cmd/fbsql/main.go b/cmd/fbsql/main.go new file mode 100644 index 000000000..d498d1703 --- /dev/null +++ b/cmd/fbsql/main.go @@ -0,0 +1,13 @@ +// Copyright 2021 Molecula Corp. All rights reserved. +package main + +import ( + "os" + + "github.com/featurebasedb/featurebase/v3/cmd" +) + +func main() { + command := cmd.NewCLICommand(os.Stderr) + command.Execute() +} diff --git a/cmd/root.go b/cmd/root.go index 22f562867..9a67365fc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -63,11 +63,12 @@ at https://docs.featurebase.com/. ` + pilosa.VersionInfo(true) + "\n", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { v := viper.New() - if cmd.Use == "dax" { + + switch cmd.Use { + case "dax": v.Set("future.rename", true) // always use FEATUREBASE env for dax } - err := setAllConfig(v, cmd.Flags()) - if err != nil { + if err := setAllConfig(v, cmd.Flags(), ""); err != nil { return err } @@ -104,7 +105,6 @@ at https://docs.featurebase.com/. rc.AddCommand(newServeCmd(stderr)) rc.AddCommand(newHolderCmd(stderr)) rc.AddCommand(newKeygenCommand(logdest)) - rc.AddCommand(newCLICommand(logdest)) rc.AddCommand(newDAXCommand(stderr)) rc.AddCommand(newDataframeCsvLoaderCommand(logdest)) rc.AddCommand(newPreSortCommand(logdest)) @@ -124,17 +124,18 @@ at https://docs.featurebase.com/. // setAllConfig looks for environment variables which are capitalized versions // of the flag names with dashes replaced by underscores, and prefixed with // envPrefix plus an underscore. -func setAllConfig(v *viper.Viper, flags *pflag.FlagSet) error { // nolint: unparam +func setAllConfig(v *viper.Viper, flags *pflag.FlagSet, envPrefix string) error { // nolint: unparam // add cmd line flag def to viper err := v.BindPFlags(flags) if err != nil { return err } - envPrefix := "PILOSA" - rename := v.GetBool("future.rename") - if rename { - envPrefix = "FEATUREBASE" + if envPrefix == "" { + envPrefix = "PILOSA" + if v.GetBool("future.rename") { + envPrefix = "FEATUREBASE" + } } // add env to viper diff --git a/ctl/cli.go b/ctl/cli.go new file mode 100644 index 000000000..564f708a5 --- /dev/null +++ b/ctl/cli.go @@ -0,0 +1,39 @@ +package ctl + +import ( + "github.com/featurebasedb/featurebase/v3/cli" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// BuildCLIFlags attaches a set of flags to the command for a cli instance. +func BuildCLIFlags(cmd *cobra.Command, cliCmd *cli.Command) { + flags := cmd.Flags() + + // Base struct flags. + flags.StringSliceVarP(&cliCmd.Commands, "command", "c", cliCmd.Commands, "Command to run in non-interactive mode. Provide multiple flags to execute more than one command. All `--command` flags run before all `--file` flags.") + flags.StringSliceVarP(&cliCmd.Files, "file", "f", cliCmd.Files, "File to run in non-interactive mode. Provide multiple flags to execute more than one file. All `--command` flags run before all `--file` flags.") + + // Config flags. + flags.AddFlagSet(cliConfigFlagSet(cliCmd.Config)) +} + +// cliConfigFlagSet returns a pflag.FlagSet for the CLI Config struct. +func cliConfigFlagSet(cfg *cli.Config) *pflag.FlagSet { + flags := pflag.NewFlagSet("cli", pflag.ExitOnError) + + flags.StringVarP(&cfg.Host, "host", "", cfg.Host, "hostname of FeatureBase.") + flags.StringVarP(&cfg.Port, "port", "", cfg.Port, "port of FeatureBase.") + flags.StringVar(&cfg.HistoryPath, "history-path", cfg.HistoryPath, "path for history files.") + flags.StringVar(&cfg.OrganizationID, "org-id", cfg.OrganizationID, "OrganizationID.") + flags.StringVar(&cfg.Database, "db", cfg.Database, "Name of the database to connect to.") + + flags.StringVar(&cfg.CloudAuth.ClientID, "client-id", cfg.CloudAuth.ClientID, "Cognito Client ID for FeatureBase Cloud access.") + flags.StringVar(&cfg.CloudAuth.Region, "region", cfg.CloudAuth.Region, "Cloud region for FeatureBase Cloud access (e.g. us-east-2).") + flags.StringVar(&cfg.CloudAuth.Email, "email", cfg.CloudAuth.Email, "Email address for FeatureBase Cloud access.") + flags.StringVar(&cfg.CloudAuth.Password, "password", cfg.CloudAuth.Password, "Password for FeatureBase Cloud access.") + + flags.String("config", "", "Configuration file to read from.") + + return flags +}