diff --git a/README.md b/README.md index 576439da..807e2730 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ switches are most important to you to have implemented next in the new sqlcmd. - `:Connect` now has an optional `-G` parameter to select one of the authentication methods for Azure SQL Database - `SqlAuthentication`, `ActiveDirectoryDefault`, `ActiveDirectoryIntegrated`, `ActiveDirectoryServicePrincipal`, `ActiveDirectoryManagedIdentity`, `ActiveDirectoryPassword`. If `-G` is not provided, either Integrated security or SQL Authentication will be used, dependent on the presence of a `-U` username parameter. - The new `--driver-logging-level` command line parameter allows you to see traces from the `go-mssqldb` client driver. Use `64` to see all traces. - Sqlcmd can now print results using a vertical format. Use the new `--vertical` command line option to set it. It's also controlled by the `SQLCMDFORMAT` scripting variable. +- `:help` displays a list of available sqlcmd commands. ``` 1> select session_id, client_interface_name, program_name from sys.dm_exec_sessions where session_id=@@spid @@ -183,6 +184,14 @@ client_interface_name go-mssqldb program_name sqlcmd ``` +- `:perftrace` redirects performance statistics output to a file, stderr, or stdout. Requires the `-p` flag (print statistics) to produce timing data. + +``` +1> :perftrace c:/logs/perf.txt +1> select 1 +2> go +``` + - `sqlcmd` supports shared memory and named pipe transport. Use the appropriate protocol prefix on the server name to force a protocol: * `lpc` for shared memory, only for a localhost. `sqlcmd -S lpc:.` * `np` for named pipes. Or use the UNC named pipe path as the server name: `sqlcmd -S \\myserver\pipe\sql\query` diff --git a/cmd/sqlcmd/sqlcmd.go b/cmd/sqlcmd/sqlcmd.go index 5abc0860..9e32eee5 100644 --- a/cmd/sqlcmd/sqlcmd.go +++ b/cmd/sqlcmd/sqlcmd.go @@ -943,6 +943,7 @@ func run(vars *sqlcmd.Variables, args *SQLCmdArguments) (int, error) { } s.SetOutput(nil) s.SetError(nil) + s.SetStat(nil) return s.Exitcode, err } diff --git a/pkg/sqlcmd/commands.go b/pkg/sqlcmd/commands.go index 66dd1dba..96ae2b84 100644 --- a/pkg/sqlcmd/commands.go +++ b/pkg/sqlcmd/commands.go @@ -6,6 +6,7 @@ package sqlcmd import ( "flag" "fmt" + "io" "os" "regexp" "sort" @@ -29,6 +30,9 @@ type Command struct { name string // whether the command is a system command isSystem bool + // help is the text shown by :help for this command. + // Multiple lines are allowed. Empty means hidden from :help. + help string } // Commands is the set of sqlcmd command implementations @@ -41,77 +45,107 @@ func newCommands() Commands { regex: regexp.MustCompile(`(?im)^[\t ]*?:?EXIT([\( \t]+.*\)*$|$)`), action: exitCommand, name: "EXIT", + help: ":exit\n - Quits sqlcmd immediately.\n" + + ":exit()\n - Execute statement cache; quit with no return value.\n" + + ":exit()\n - Execute the specified query; returns numeric result.\n", }, "QUIT": { regex: regexp.MustCompile(`(?im)^[\t ]*?:?QUIT(?:[ \t]+(.*$)|$)`), action: quitCommand, name: "QUIT", + help: ":quit\n - Quits sqlcmd immediately.\n", }, "GO": { regex: regexp.MustCompile(batchTerminatorRegex("GO")), action: goCommand, name: "GO", + help: "go []\n - Executes the statement cache (n times).\n", }, "OUT": { regex: regexp.MustCompile(`(?im)^[ \t]*:OUT(?:[ \t]+(.*$)|$)`), action: outCommand, name: "OUT", + help: ":out |stderr|stdout\n - Redirects query output to a file, stderr, or stdout.\n", }, "ERROR": { regex: regexp.MustCompile(`(?im)^[ \t]*:ERROR(?:[ \t]+(.*$)|$)`), action: errorCommand, name: "ERROR", + help: ":error \n - Redirects error output to a file, stderr, or stdout.\n", }, "READFILE": { regex: regexp.MustCompile(`(?im)^[ \t]*:R(?:[ \t]+(.*$)|$)`), action: readFileCommand, name: "READFILE", + help: ":r \n - Append file contents to the statement cache.\n", }, "SETVAR": { regex: regexp.MustCompile(`(?im)^[ \t]*:SETVAR(?:[ \t]+(.*$)|$)`), action: setVarCommand, name: "SETVAR", + help: ":setvar {variable}\n - Removes a sqlcmd scripting variable.\n" + + ":setvar \n - Sets a sqlcmd scripting variable.\n", }, "LISTVAR": { regex: regexp.MustCompile(`(?im)^[\t ]*?:LISTVAR(?:[ \t]+(.*$)|$)`), action: listVarCommand, name: "LISTVAR", + help: ":listvar\n - Lists the set sqlcmd scripting variables.\n", }, "RESET": { regex: regexp.MustCompile(`(?im)^[ \t]*?:?RESET(?:[ \t]+(.*$)|$)`), action: resetCommand, name: "RESET", + help: ":reset\n - Discards the statement cache.\n", }, "LIST": { regex: regexp.MustCompile(`(?im)^[ \t]*:LIST(?:[ \t]+(.*$)|$)`), action: listCommand, name: "LIST", + help: ":list\n - Prints the content of the statement cache.\n", }, "CONNECT": { regex: regexp.MustCompile(`(?im)^[ \t]*:CONNECT(?:[ \t]+(.*$)|$)`), action: connectCommand, name: "CONNECT", + help: ":connect server[\\instance] [-l timeout] [-U user [-P password]]\n - Connects to a SQL Server instance.\n", }, "EXEC": { regex: regexp.MustCompile(`(?im)^[ \t]*?:?!!(.*$)`), action: execCommand, name: "EXEC", isSystem: true, + help: ":!! []\n - Executes a command in the operating system shell.\n", }, "EDIT": { regex: regexp.MustCompile(`(?im)^[\t ]*?:?ED(?:[ \t]+(.*$)|$)`), action: editCommand, name: "EDIT", isSystem: true, + help: ":ed\n - Edits the current or last executed statement cache.\n", }, "ONERROR": { regex: regexp.MustCompile(`(?im)^[\t ]*?:?ON ERROR(?:[ \t]+(.*$)|$)`), action: onerrorCommand, name: "ONERROR", + help: ":on error [exit|ignore]\n - Action for batch or sqlcmd command errors.\n", }, "XML": { regex: regexp.MustCompile(`(?im)^[\t ]*?:XML(?:[ \t]+(.*$)|$)`), action: xmlCommand, name: "XML", + help: ":xml [on|off]\n - Sets XML output mode.\n", + }, + "HELP": { + regex: regexp.MustCompile(`(?im)^[ \t]*:HELP(?:[ \t]+(.*$)|$)`), + action: helpCommand, + name: "HELP", + help: ":help\n - Shows this list of commands.\n", + }, + "PERFTRACE": { + regex: regexp.MustCompile(`(?im)^[ \t]*:PERFTRACE(?:[ \t]+(.*$)|$)`), + action: perftraceCommand, + name: "PERFTRACE", + help: ":perftrace |stderr|stdout\n - Redirects timing output to a file, stderr, or stdout.\n", }, } } @@ -300,61 +334,48 @@ func goCommand(s *Sqlcmd, args []string, line uint) error { return nil } -// outCommand changes the output writer to use a file -func outCommand(s *Sqlcmd, args []string, line uint) error { +// redirectWriter resolves a :out/:error/:perftrace argument to stderr, stdout, +// or a new file, then calls setter with the result. +func redirectWriter(s *Sqlcmd, args []string, line uint, name string, setter func(io.WriteCloser)) error { if len(args) == 0 || args[0] == "" { - return InvalidCommandError("OUT", line) + return InvalidCommandError(name, line) } filePath, err := resolveArgumentVariables(s, []rune(args[0]), true) if err != nil { return err } - switch { - case strings.EqualFold(filePath, "stdout"): - s.SetOutput(os.Stdout) case strings.EqualFold(filePath, "stderr"): - s.SetOutput(os.Stderr) + setter(os.Stderr) + case strings.EqualFold(filePath, "stdout"): + setter(os.Stdout) default: o, err := os.OpenFile(filePath, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return InvalidFileError(err, args[0]) } - if s.UnicodeOutputFile { - // ODBC sqlcmd doesn't write a BOM but we will. - // Maybe the endian-ness should be configurable. - win16le := unicode.UTF16(unicode.LittleEndian, unicode.UseBOM) - encoder := transform.NewWriter(o, win16le.NewEncoder()) - s.SetOutput(encoder) - } else { - s.SetOutput(o) - } + setter(o) } return nil } -// errorCommand changes the error writer to use a file -func errorCommand(s *Sqlcmd, args []string, line uint) error { - if len(args) == 0 || args[0] == "" { - return InvalidCommandError("ERROR", line) - } - filePath, err := resolveArgumentVariables(s, []rune(args[0]), true) - if err != nil { - return err +// outCommand changes the output writer to use a file +func outCommand(s *Sqlcmd, args []string, line uint) error { + if !s.UnicodeOutputFile { + return redirectWriter(s, args, line, "OUT", s.SetOutput) } - switch { - case strings.EqualFold(filePath, "stderr"): - s.SetError(os.Stderr) - case strings.EqualFold(filePath, "stdout"): - s.SetError(os.Stdout) - default: - o, err := os.OpenFile(filePath, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return InvalidFileError(err, args[0]) + return redirectWriter(s, args, line, "OUT", func(w io.WriteCloser) { + if w != os.Stdout && w != os.Stderr { + win16le := unicode.UTF16(unicode.LittleEndian, unicode.UseBOM) + w = transform.NewWriter(w, win16le.NewEncoder()) } - s.SetError(o) - } - return nil + s.SetOutput(w) + }) +} + +// errorCommand changes the error writer to use a file +func errorCommand(s *Sqlcmd, args []string, line uint) error { + return redirectWriter(s, args, line, "ERROR", s.SetError) } func readFileCommand(s *Sqlcmd, args []string, line uint) error { @@ -596,6 +617,43 @@ func xmlCommand(s *Sqlcmd, args []string, line uint) error { return nil } +func helpCommand(s *Sqlcmd, args []string, line uint) error { + // :HELP shows help for a single command + if len(args) > 0 && strings.TrimSpace(args[0]) != "" { + key := strings.ToUpper(strings.TrimSpace(args[0])) + if cmd, ok := s.Cmd[key]; ok && cmd.help != "" { + _, err := s.GetOutput().Write([]byte(cmd.help)) + return err + } + // Unknown command name -- fall through to full listing + } + + // Collect and sort by command name for stable output order + type entry struct { + name string + help string + } + entries := make([]entry, 0, len(s.Cmd)) + for _, cmd := range s.Cmd { + if cmd.help != "" { + entries = append(entries, entry{cmd.name, cmd.help}) + } + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].help < entries[j].help + }) + var b strings.Builder + for _, e := range entries { + b.WriteString(e.help) + } + _, err := s.GetOutput().Write([]byte(b.String())) + return err +} + +func perftraceCommand(s *Sqlcmd, args []string, line uint) error { + return redirectWriter(s, args, line, "PERFTRACE", s.SetStat) +} + func resolveArgumentVariables(s *Sqlcmd, arg []rune, failOnUnresolved bool) (string, error) { var b *strings.Builder end := len(arg) diff --git a/pkg/sqlcmd/commands_test.go b/pkg/sqlcmd/commands_test.go index 56d509da..1dcf02ff 100644 --- a/pkg/sqlcmd/commands_test.go +++ b/pkg/sqlcmd/commands_test.go @@ -54,6 +54,12 @@ func TestCommandParsing(t *testing.T) { {`:XML ON `, "XML", []string{`ON `}}, {`:RESET`, "RESET", []string{""}}, {`RESET`, "RESET", []string{""}}, + {`:HELP`, "HELP", []string{""}}, + {`:help`, "HELP", []string{""}}, + {`:HELP CONNECT`, "HELP", []string{"CONNECT"}}, + {`:help exit`, "HELP", []string{"exit"}}, + {`:PERFTRACE stderr`, "PERFTRACE", []string{"stderr"}}, + {`:perftrace c:/logs/perf.txt`, "PERFTRACE", []string{"c:/logs/perf.txt"}}, } for _, test := range commands { @@ -464,3 +470,97 @@ func TestExitCommandAppendsParameterToCurrentBatch(t *testing.T) { } } + +func TestHelpCommand(t *testing.T) { + s := New(nil, "", InitializeVariables(false)) + buf := &memoryBuffer{buf: new(bytes.Buffer)} + s.SetOutput(buf) + defer func() { _ = buf.Close() }() + + err := helpCommand(s, []string{""}, 1) + assert.NoError(t, err, "helpCommand should not error") + + output := buf.buf.String() + // Verify every registered command with a help field appears in output + for name, cmd := range s.Cmd { + if cmd.help != "" { + assert.Contains(t, output, cmd.help, + "help output missing text for command %s", name) + } + } + + // :HELP should show only that command's help + buf.buf.Reset() + err = helpCommand(s, []string{"CONNECT"}, 1) + assert.NoError(t, err, "helpCommand CONNECT should not error") + output = buf.buf.String() + assert.Contains(t, output, ":connect", "HELP CONNECT should show connect help") + assert.NotContains(t, output, ":exit", "HELP CONNECT should not show exit help") + + // Case-insensitive lookup + buf.buf.Reset() + err = helpCommand(s, []string{"exit"}, 1) + assert.NoError(t, err, "helpCommand exit should not error") + output = buf.buf.String() + assert.Contains(t, output, ":exit", "HELP exit should show exit help") + assert.NotContains(t, output, ":connect", "HELP exit should not show connect help") + + // Unknown command falls through to full listing + buf.buf.Reset() + err = helpCommand(s, []string{"NOSUCHCMD"}, 1) + assert.NoError(t, err, "helpCommand unknown should not error") + output = buf.buf.String() + for name, cmd := range s.Cmd { + if cmd.help != "" { + assert.Contains(t, output, cmd.help, + "unknown command should show full help, missing %s", name) + } + } +} + +func TestAllCommandsHaveHelp(t *testing.T) { + cmds := newCommands() + for name, cmd := range cmds { + assert.NotEmpty(t, cmd.help, + "command %q has no help text; add a help field to prevent it being hidden from :help", name) + } +} + +func TestPerftraceCommand(t *testing.T) { + s := New(nil, "", InitializeVariables(false)) + buf := &memoryBuffer{buf: new(bytes.Buffer)} + s.SetOutput(buf) + defer func() { _ = buf.Close() }() + + // Test empty argument returns error + err := perftraceCommand(s, []string{""}, 1) + assert.EqualError(t, err, InvalidCommandError("PERFTRACE", 1).Error(), "perftraceCommand with empty argument") + + // Test redirect to stdout + err = perftraceCommand(s, []string{"stdout"}, 1) + assert.NoError(t, err, "perftraceCommand with stdout") + assert.Equal(t, os.Stdout, s.GetStat(), "stat set to stdout") + + // Test redirect to stderr + err = perftraceCommand(s, []string{"stderr"}, 1) + assert.NoError(t, err, "perftraceCommand with stderr") + assert.Equal(t, os.Stderr, s.GetStat(), "stat set to stderr") + + // Test redirect to file + file, err := os.CreateTemp("", "sqlcmdperf") + assert.NoError(t, err, "os.CreateTemp") + defer func() { _ = os.Remove(file.Name()) }() + fileName := file.Name() + _ = file.Close() + + err = perftraceCommand(s, []string{fileName}, 1) + assert.NoError(t, err, "perftraceCommand with file path") + // Clean up by setting stat to nil + s.SetStat(nil) + + // Test variable resolution + s.vars.Set("myvar", "stdout") + err = perftraceCommand(s, []string{"$(myvar)"}, 1) + assert.NoError(t, err, "perftraceCommand with a variable") + assert.Equal(t, os.Stdout, s.GetStat(), "stat set to stdout using a variable") +} diff --git a/pkg/sqlcmd/sqlcmd.go b/pkg/sqlcmd/sqlcmd.go index 93637a02..5102779f 100644 --- a/pkg/sqlcmd/sqlcmd.go +++ b/pkg/sqlcmd/sqlcmd.go @@ -67,6 +67,7 @@ type Sqlcmd struct { db *sql.Conn out io.WriteCloser err io.WriteCloser + stat io.WriteCloser batch *Batch echoFileLines bool // Exitcode is returned to the operating system when the process exits @@ -236,6 +237,23 @@ func (s *Sqlcmd) SetError(e io.WriteCloser) { s.err = e } +// GetStat returns the io.Writer to use for performance statistics. +// Falls back to GetOutput() when no :perftrace redirection is active. +func (s *Sqlcmd) GetStat() io.Writer { + if s.stat == nil { + return s.GetOutput() + } + return s.stat +} + +// SetStat sets the io.WriteCloser to use for performance statistics +func (s *Sqlcmd) SetStat(st io.WriteCloser) { + if s.stat != nil && s.stat != os.Stderr && s.stat != os.Stdout { + _ = s.stat.Close() + } + s.stat = st +} + // WriteError writes the error on specified stream func (s *Sqlcmd) WriteError(stream io.Writer, err error) { if serr, ok := err.(SqlcmdError); ok {