diff --git a/README.md b/README.md index fe26e192..84aeed06 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,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 @@ -163,6 +164,14 @@ client_interface_name go-mssqldb program_name sqlcmd ``` +- `:perftrace` redirects performance statistics output to a file, stderr, or stdout. Use in conjunction with `-p` flag. + +``` +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 ea655b47..8091ebee 100644 --- a/cmd/sqlcmd/sqlcmd.go +++ b/cmd/sqlcmd/sqlcmd.go @@ -909,6 +909,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..1b80af3f 100644 --- a/pkg/sqlcmd/commands.go +++ b/pkg/sqlcmd/commands.go @@ -113,6 +113,16 @@ func newCommands() Commands { action: xmlCommand, name: "XML", }, + "HELP": { + regex: regexp.MustCompile(`(?im)^[ \t]*:HELP(?:[ \t]+(.*$)|$)`), + action: helpCommand, + name: "HELP", + }, + "PERFTRACE": { + regex: regexp.MustCompile(`(?im)^[ \t]*:PERFTRACE(?:[ \t]+(.*$)|$)`), + action: perftraceCommand, + name: "PERFTRACE", + }, } } @@ -596,6 +606,77 @@ func xmlCommand(s *Sqlcmd, args []string, line uint) error { return nil } +// helpCommand displays the list of available sqlcmd commands +func helpCommand(s *Sqlcmd, args []string, line uint) error { + helpText := `:!! [] + - Executes a command in the operating system shell. +:connect server[\instance] [-l timeout] [-U user [-P password]] + - Connects to a SQL Server instance. +:ed + - Edits the current or last executed statement cache. +:error + - Redirects error output to a file, stderr, or stdout. +:exit + - Quits sqlcmd immediately. +:exit() + - Execute statement cache; quit with no return value. +:exit() + - Execute the specified query; returns numeric result. +go [] + - Executes the statement cache (n times). +:help + - Shows this list of commands. +:list + - Prints the content of the statement cache. +:listvar + - Lists the set sqlcmd scripting variables. +:on error [exit|ignore] + - Action for batch or sqlcmd command errors. +:out |stderr|stdout + - Redirects query output to a file, stderr, or stdout. +:perftrace |stderr|stdout + - Redirects timing output to a file, stderr, or stdout. +:quit + - Quits sqlcmd immediately. +:r + - Append file contents to the statement cache. +:reset + - Discards the statement cache. +:setvar {variable} + - Removes a sqlcmd scripting variable. +:setvar + - Sets a sqlcmd scripting variable. +:xml [on|off] + - Sets XML output mode. +` + _, err := s.GetOutput().Write([]byte(helpText)) + return err +} + +// perftraceCommand changes the performance statistics writer to use a file +func perftraceCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) == 0 || args[0] == "" { + return InvalidCommandError("PERFTRACE", line) + } + filePath, err := resolveArgumentVariables(s, []rune(args[0]), true) + if err != nil { + return err + } + switch { + case strings.EqualFold(filePath, "stderr"): + s.SetStat(os.Stderr) + case strings.EqualFold(filePath, "stdout"): + s.SetStat(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]) + } + s.SetStat(o) + } + return nil +} + 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 6197aa3f..5cea7ad3 100644 --- a/pkg/sqlcmd/commands_test.go +++ b/pkg/sqlcmd/commands_test.go @@ -54,6 +54,10 @@ func TestCommandParsing(t *testing.T) { {`:XML ON `, "XML", []string{`ON `}}, {`:RESET`, "RESET", []string{""}}, {`RESET`, "RESET", []string{""}}, + {`:HELP`, "HELP", []string{""}}, + {`:help`, "HELP", []string{""}}, + {`:PERFTRACE stderr`, "PERFTRACE", []string{"stderr"}}, + {`:perftrace c:/logs/perf.txt`, "PERFTRACE", []string{"c:/logs/perf.txt"}}, } for _, test := range commands { @@ -458,3 +462,62 @@ func TestExitCommandAppendsParameterToCurrentBatch(t *testing.T) { } } + +func TestHelpCommand(t *testing.T) { + s, buf := setupSqlCmdWithMemoryOutput(t) + defer buf.Close() + s.SetOutput(buf) + + err := helpCommand(s, []string{""}, 1) + assert.NoError(t, err, "helpCommand should not error") + + output := buf.buf.String() + // Verify key commands are listed + assert.Contains(t, output, ":connect", "help should list :connect") + assert.Contains(t, output, ":exit", "help should list :exit") + assert.Contains(t, output, ":help", "help should list :help") + assert.Contains(t, output, ":setvar", "help should list :setvar") + assert.Contains(t, output, ":listvar", "help should list :listvar") + assert.Contains(t, output, ":out", "help should list :out") + assert.Contains(t, output, ":error", "help should list :error") + assert.Contains(t, output, ":perftrace", "help should list :perftrace") + assert.Contains(t, output, ":r", "help should list :r") + assert.Contains(t, output, "go", "help should list go") +} + +func TestPerftraceCommand(t *testing.T) { + s, buf := setupSqlCmdWithMemoryOutput(t) + defer 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 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 5e572a94..485cd905 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,22 @@ func (s *Sqlcmd) SetError(e io.WriteCloser) { s.err = e } +// GetStat returns the io.Writer to use for performance statistics +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 {