From 56b4cc0bf0f04cbe0341e2ec605a3733dae6efb8 Mon Sep 17 00:00:00 2001 From: David Levy Date: Sat, 24 Jan 2026 22:20:28 -0600 Subject: [PATCH 1/5] Implement :perftrace command Add the :perftrace command to redirect performance statistics output to a file, stderr, or stdout. This works in conjunction with the -p flag to control where timing statistics are written. Syntax: :perftrace |stderr|stdout Changes: - Added stat writer field to Sqlcmd struct - Added GetStat/SetStat methods - Added PERFTRACE command with file/stdout/stderr support - Added tests for command parsing and functionality This improves compatibility with legacy ODBC sqlcmd. --- README.md | 8 +++++++ pkg/sqlcmd/commands.go | 29 +++++++++++++++++++++++++ pkg/sqlcmd/commands_test.go | 42 +++++++++++++++++++++++++++++++++++++ pkg/sqlcmd/sqlcmd.go | 17 +++++++++++++++ 4 files changed, 96 insertions(+) diff --git a/README.md b/README.md index fe26e192..4b3c7aef 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,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/pkg/sqlcmd/commands.go b/pkg/sqlcmd/commands.go index 66dd1dba..e4f84be4 100644 --- a/pkg/sqlcmd/commands.go +++ b/pkg/sqlcmd/commands.go @@ -113,6 +113,11 @@ func newCommands() Commands { action: xmlCommand, name: "XML", }, + "PERFTRACE": { + regex: regexp.MustCompile(`(?im)^[ \t]*:PERFTRACE(?:[ \t]+(.*$)|$)`), + action: perftraceCommand, + name: "PERFTRACE", + }, } } @@ -357,6 +362,30 @@ func errorCommand(s *Sqlcmd, args []string, line uint) error { return nil } +// 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 readFileCommand(s *Sqlcmd, args []string, line uint) error { if args == nil || len(args) != 1 { return InvalidCommandError(":R", line) diff --git a/pkg/sqlcmd/commands_test.go b/pkg/sqlcmd/commands_test.go index 6197aa3f..4ad72476 100644 --- a/pkg/sqlcmd/commands_test.go +++ b/pkg/sqlcmd/commands_test.go @@ -54,6 +54,8 @@ func TestCommandParsing(t *testing.T) { {`:XML ON `, "XML", []string{`ON `}}, {`:RESET`, "RESET", []string{""}}, {`RESET`, "RESET", []string{""}}, + {`:PERFTRACE stdout`, "PERFTRACE", []string{"stdout"}}, + {`:perftrace c:\logs\perf.txt`, "PERFTRACE", []string{`c:\logs\perf.txt`}}, } for _, test := range commands { @@ -458,3 +460,43 @@ func TestExitCommandAppendsParameterToCurrentBatch(t *testing.T) { } } + +func TestPerftraceCommand(t *testing.T) { + s, buf := setupSqlCmdWithMemoryOutput(t) + defer s.SetStat(nil) + defer buf.Close() + file, err := os.CreateTemp("", "sqlcmdperf") + assert.NoError(t, err, "os.CreateTemp") + defer os.Remove(file.Name()) + fileName := file.Name() + _ = file.Close() + + // Test empty file name returns error + err = perftraceCommand(s, []string{""}, 1) + assert.EqualError(t, err, InvalidCommandError("PERFTRACE", 1).Error(), "perftraceCommand with empty file name") + + // Test valid file name + err = perftraceCommand(s, []string{fileName}, 1) + assert.NoError(t, err, "perftraceCommand") + + // Test that stat writer is set + statWriter := s.GetStat() + assert.NotNil(t, statWriter, "stat writer should be set") + assert.NotEqual(t, s.GetOutput(), statWriter, "stat writer should not be default output") + + // Test stdout + err = perftraceCommand(s, []string{"stdout"}, 1) + assert.NoError(t, err, "perftraceCommand stdout") + assert.Equal(t, os.Stdout, s.stat, "stat set to stdout") + + // Test stderr + err = perftraceCommand(s, []string{"stderr"}, 1) + assert.NoError(t, err, "perftraceCommand stderr") + assert.Equal(t, os.Stderr, s.stat, "stat set to stderr") + + // Test with variable + 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.stat, "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 { From ebd4b2f17bd85026b05f9876176e5900f4b5a0df Mon Sep 17 00:00:00 2001 From: David Levy Date: Sat, 24 Jan 2026 22:32:31 -0600 Subject: [PATCH 2/5] Implement :help command Add the :help command to display available sqlcmd commands. This improves compatibility with legacy ODBC sqlcmd. Changes: - Added HELP command to command registry - Added helpCommand function with full command list - Added tests for command parsing and functionality - Updated README.md --- README.md | 1 + pkg/sqlcmd/commands.go | 54 +++++++++++++++++++++++++++++++++++++ pkg/sqlcmd/commands_test.go | 23 ++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/README.md b/README.md index fe26e192..711ac9b6 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 diff --git a/pkg/sqlcmd/commands.go b/pkg/sqlcmd/commands.go index 66dd1dba..8528c4b5 100644 --- a/pkg/sqlcmd/commands.go +++ b/pkg/sqlcmd/commands.go @@ -113,6 +113,11 @@ func newCommands() Commands { action: xmlCommand, name: "XML", }, + "HELP": { + regex: regexp.MustCompile(`(?im)^[ \t]*:HELP(?:[ \t]+(.*$)|$)`), + action: helpCommand, + name: "HELP", + }, } } @@ -596,6 +601,55 @@ 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. +:serverlist + - Lists local and SQL Servers on the network. +: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 +} + 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..5d527b87 100644 --- a/pkg/sqlcmd/commands_test.go +++ b/pkg/sqlcmd/commands_test.go @@ -54,6 +54,8 @@ func TestCommandParsing(t *testing.T) { {`:XML ON `, "XML", []string{`ON `}}, {`:RESET`, "RESET", []string{""}}, {`RESET`, "RESET", []string{""}}, + {`:HELP`, "HELP", []string{""}}, + {`:help`, "HELP", []string{""}}, } for _, test := range commands { @@ -458,3 +460,24 @@ 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, ":r", "help should list :r") + assert.Contains(t, output, "go", "help should list go") +} From 4bea3f26108ee5406b27b154be308be7f8ecc52a Mon Sep 17 00:00:00 2001 From: David Levy Date: Sun, 25 Jan 2026 11:47:13 -0600 Subject: [PATCH 3/5] Fix review comments for PR #634 - Remove :serverlist and :perftrace from help text - These commands are in separate PRs and not yet merged - Help text should only list commands that exist in this branch --- pkg/sqlcmd/commands.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pkg/sqlcmd/commands.go b/pkg/sqlcmd/commands.go index 8528c4b5..d67d6c17 100644 --- a/pkg/sqlcmd/commands.go +++ b/pkg/sqlcmd/commands.go @@ -629,16 +629,12 @@ go [] - 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. -:serverlist - - Lists local and SQL Servers on the network. :setvar {variable} - Removes a sqlcmd scripting variable. :setvar From 3b3ca33c1b27b9c39cc0db27aaf526035b529ff2 Mon Sep 17 00:00:00 2001 From: David Levy Date: Sun, 25 Jan 2026 13:03:58 -0600 Subject: [PATCH 4/5] Address Copilot review comments on perftrace command - Fix regex pattern for consistency with other commands - Fix README path to use forward slashes for cross-platform - Add test cases for :PERFTRACE command parsing - Add :perftrace assertion in TestHelpCommand - Add TestPerftraceCommand for comprehensive testing --- README.md | 2 +- pkg/sqlcmd/commands.go | 2 +- pkg/sqlcmd/commands_test.go | 40 +++++++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f3a89de2..84aeed06 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ 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> :perftrace c:/logs/perf.txt 1> select 1 2> go ``` diff --git a/pkg/sqlcmd/commands.go b/pkg/sqlcmd/commands.go index 65fc4e80..1b80af3f 100644 --- a/pkg/sqlcmd/commands.go +++ b/pkg/sqlcmd/commands.go @@ -119,7 +119,7 @@ func newCommands() Commands { name: "HELP", }, "PERFTRACE": { - regex: regexp.MustCompile(`(?im)^[\t ]*?:PERFTRACE(?:[ \t]+(.*$)|$)`), + regex: regexp.MustCompile(`(?im)^[ \t]*:PERFTRACE(?:[ \t]+(.*$)|$)`), action: perftraceCommand, name: "PERFTRACE", }, diff --git a/pkg/sqlcmd/commands_test.go b/pkg/sqlcmd/commands_test.go index 5d527b87..5cea7ad3 100644 --- a/pkg/sqlcmd/commands_test.go +++ b/pkg/sqlcmd/commands_test.go @@ -56,6 +56,8 @@ func TestCommandParsing(t *testing.T) { {`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 { @@ -478,6 +480,44 @@ func TestHelpCommand(t *testing.T) { 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") +} From 709738da7d20c8dfc0c2e122d0147ff6e3fbbf97 Mon Sep 17 00:00:00 2001 From: David Levy Date: Sun, 25 Jan 2026 15:46:18 -0600 Subject: [PATCH 5/5] fix: add SetStat(nil) cleanup for perftrace file handles --- cmd/sqlcmd/sqlcmd.go | 1 + 1 file changed, 1 insertion(+) 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 }