From 20182ab2da5eae8800c3841f24d465f6e6e8c384 Mon Sep 17 00:00:00 2001 From: David Levy Date: Sat, 24 Jan 2026 22:11:46 -0600 Subject: [PATCH 1/2] Implement -p flag for print statistics Add support for the -p flag which prints performance statistics after each batch execution: - -p (or -p0): Standard human-readable format - -p1: Colon-separated format for parsing Statistics include network packet size, transaction count, and timing information (total ms, average ms, transactions per second). This improves compatibility with legacy ODBC sqlcmd. --- README.md | 10 +++++++++ cmd/sqlcmd/sqlcmd.go | 13 ++++++++++++ pkg/sqlcmd/sqlcmd.go | 44 +++++++++++++++++++++++++++++++++++++-- pkg/sqlcmd/sqlcmd_test.go | 42 +++++++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fe26e192..4c273450 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,16 @@ 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. +- `-p` prints performance statistics after each batch execution. Use `-p` for standard format or `-p1` for colon-separated format suitable for parsing. + +``` +1> select 1 +2> go + +Network packet size (bytes): 4096 +1 xact[s]: +Clock Time (ms.): total 5 avg 5.00 (200.00 xacts per sec.) +``` ``` 1> select session_id, client_interface_name, program_name from sys.dm_exec_sessions where session_id=@@spid diff --git a/cmd/sqlcmd/sqlcmd.go b/cmd/sqlcmd/sqlcmd.go index ea655b47..40964cdf 100644 --- a/cmd/sqlcmd/sqlcmd.go +++ b/cmd/sqlcmd/sqlcmd.go @@ -82,6 +82,7 @@ type SQLCmdArguments struct { ChangePassword string ChangePasswordAndExit string TraceFile string + PrintStatistics *int // Keep Help at the end of the list Help bool } @@ -126,6 +127,7 @@ const ( disableCmdAndWarn = "disable-cmd-and-warn" listServers = "list-servers" removeControlCharacters = "remove-control-characters" + printStatistics = "print-statistics" ) func encryptConnectionAllowsTLS(value string) bool { @@ -393,6 +395,7 @@ func SetScreenWidthFlags(args *SQLCmdArguments, rootCmd *cobra.Command) { args.DisableCmd = getOptionalIntArgument(rootCmd, disableCmdAndWarn) args.ErrorsToStderr = getOptionalIntArgument(rootCmd, errorsToStderr) args.RemoveControlCharacters = getOptionalIntArgument(rootCmd, removeControlCharacters) + args.PrintStatistics = getOptionalIntArgument(rootCmd, printStatistics) } func setFlags(rootCmd *cobra.Command, args *SQLCmdArguments) { @@ -475,6 +478,7 @@ func setFlags(rootCmd *cobra.Command, args *SQLCmdArguments) { _ = rootCmd.Flags().BoolP("client-regional-setting", "R", false, localizer.Sprintf("Provided for backward compatibility. Client regional settings are not used")) _ = rootCmd.Flags().IntP(removeControlCharacters, "k", 0, localizer.Sprintf("%s Remove control characters from output. Pass 1 to substitute a space per character, 2 for a space per consecutive characters", "-k [1|2]")) rootCmd.Flags().BoolVarP(&args.EchoInput, "echo-input", "e", false, localizer.Sprintf("Echo input")) + _ = rootCmd.Flags().IntP(printStatistics, "p", 0, localizer.Sprintf("%s Print performance statistics after each batch. Pass 1 for colon-separated format", "-p[1]")) rootCmd.Flags().IntVarP(&args.QueryTimeout, "query-timeout", "t", 0, "Query timeout") rootCmd.Flags().BoolVarP(&args.EnableColumnEncryption, "enable-column-encryption", "g", false, localizer.Sprintf("Enable column encryption")) rootCmd.Flags().StringVarP(&args.ChangePassword, "change-password", "z", "", localizer.Sprintf("New password")) @@ -543,6 +547,14 @@ func normalizeFlags(cmd *cobra.Command) error { err = invalidParameterError("-k", v, "1", "2") return pflag.NormalizedName("") } + case printStatistics: + switch v { + case "0", "1": + return pflag.NormalizedName(name) + default: + err = invalidParameterError("-p", v, "1") + return pflag.NormalizedName("") + } } return pflag.NormalizedName(name) @@ -812,6 +824,7 @@ func run(vars *sqlcmd.Variables, args *SQLCmdArguments) (int, error) { s.SetupCloseHandler() defer s.StopCloseHandler() s.UnicodeOutputFile = args.UnicodeOutputFile + s.PrintStatistics = args.PrintStatistics if args.DisableCmd != nil { s.Cmd.DisableSysCommands(args.errorOnBlockedCmd()) diff --git a/pkg/sqlcmd/sqlcmd.go b/pkg/sqlcmd/sqlcmd.go index 5e572a94..7a601e3e 100644 --- a/pkg/sqlcmd/sqlcmd.go +++ b/pkg/sqlcmd/sqlcmd.go @@ -86,8 +86,11 @@ type Sqlcmd struct { UnicodeOutputFile bool // EchoInput tells the GO command to print the batch text before running the query EchoInput bool - colorizer color.Colorizer - termchan chan os.Signal + // PrintStatistics controls printing of performance statistics after each batch + // nil means disabled, 0 means standard format, 1 means colon-separated format + PrintStatistics *int + colorizer color.Colorizer + termchan chan os.Signal } // New creates a new Sqlcmd instance. @@ -421,6 +424,7 @@ func (s *Sqlcmd) getRunnableQuery(q string) string { // -102: Conversion error occurred when selecting return value func (s *Sqlcmd) runQuery(query string) (int, error) { retcode := -101 + startTime := time.Now() s.Format.BeginBatch(query, s.vars, s.GetOutput(), s.GetError()) ctx := context.Background() timeout := s.vars.QueryTimeoutSeconds() @@ -508,6 +512,8 @@ func (s *Sqlcmd) runQuery(query string) (int, error) { } } s.Format.EndBatch() + elapsedMs := time.Since(startTime).Milliseconds() + s.printStatistics(elapsedMs, 1) return retcode, qe } @@ -580,3 +586,37 @@ func (s *Sqlcmd) SetupCloseHandler() { func (s *Sqlcmd) StopCloseHandler() { signal.Stop(s.termchan) } + +// printStatistics prints performance statistics after a batch execution +// if PrintStatistics is enabled +func (s *Sqlcmd) printStatistics(elapsedMs int64, numBatches int) { + if s.PrintStatistics == nil || numBatches <= 0 { + return + } + + // Get packet size from connect settings or use default + packetSize := s.Connect.PacketSize + if packetSize <= 0 { + packetSize = 4096 // default packet size + } + + // Ensure minimum 1ms for calculations + if elapsedMs < 1 { + elapsedMs = 1 + } + + avgTime := float64(elapsedMs) / float64(numBatches) + batchesPerSec := float64(numBatches) / (float64(elapsedMs) / 1000.0) + + out := s.GetOutput() + if *s.PrintStatistics == 1 { + // Colon-separated format: n:x:t1:t2:t3 + // packetSize:numBatches:totalTime:avgTime:batchesPerSec + fmt.Fprintf(out, "\n%d:%d:%d:%.2f:%.2f \n", packetSize, numBatches, elapsedMs, avgTime, batchesPerSec) + } else { + // Standard format + fmt.Fprintf(out, "\nNetwork packet size (bytes): %d\n", packetSize) + fmt.Fprintf(out, "%d xact[s]:\n", numBatches) + fmt.Fprintf(out, "Clock Time (ms.): total %7d avg %.2f (%.2f xacts per sec.)\n", elapsedMs, avgTime, batchesPerSec) + } +} diff --git a/pkg/sqlcmd/sqlcmd_test.go b/pkg/sqlcmd/sqlcmd_test.go index dfe97d1a..ac2390bb 100644 --- a/pkg/sqlcmd/sqlcmd_test.go +++ b/pkg/sqlcmd/sqlcmd_test.go @@ -705,3 +705,45 @@ func TestSqlcmdPrefersSharedMemoryProtocol(t *testing.T) { assert.EqualValuesf(t, "np", msdsn.ProtocolParsers[3].Protocol(), "np should be fourth protocol") } + +func TestPrintStatisticsStandardFormat(t *testing.T) { + s, buf := setupSqlCmdWithMemoryOutput(t) + defer buf.Close() + standardFormat := 0 + s.PrintStatistics = &standardFormat + s.Connect.PacketSize = 4096 + _, err := s.runQuery("SELECT 1") + assert.NoError(t, err, "runQuery failed") + output := buf.buf.String() + // Standard format should contain specific phrases + assert.Contains(t, output, "Network packet size (bytes): 4096", "Should contain packet size") + assert.Contains(t, output, "xact[s]:", "Should contain xacts label") + assert.Contains(t, output, "Clock Time (ms.):", "Should contain clock time label") + assert.Contains(t, output, "xacts per sec.", "Should contain xacts per sec") +} + +func TestPrintStatisticsColonFormat(t *testing.T) { + s, buf := setupSqlCmdWithMemoryOutput(t) + defer buf.Close() + colonFormat := 1 + s.PrintStatistics = &colonFormat + s.Connect.PacketSize = 8192 + _, err := s.runQuery("SELECT 1") + assert.NoError(t, err, "runQuery failed") + output := buf.buf.String() + // Colon format: packetSize:numBatches:totalTime:avgTime:batchesPerSec + // Should start with 8192:1: + assert.Contains(t, output, "8192:1:", "Should contain packet size and batch count in colon format") +} + +func TestPrintStatisticsDisabled(t *testing.T) { + s, buf := setupSqlCmdWithMemoryOutput(t) + defer buf.Close() + // PrintStatistics is nil by default (disabled) + _, err := s.runQuery("SELECT 1") + assert.NoError(t, err, "runQuery failed") + output := buf.buf.String() + // Should not contain statistics output + assert.NotContains(t, output, "Network packet size", "Should not contain packet size when disabled") + assert.NotContains(t, output, "xact[s]:", "Should not contain xacts label when disabled") +} From 0cbb884d06a32ac5a94b76b6a5b4b24f31a95513 Mon Sep 17 00:00:00 2001 From: David Levy Date: Sun, 25 Jan 2026 11:35:17 -0600 Subject: [PATCH 2/2] Fix review comments for PR #631 - Add 'p' to checkDefaultValue for bare -p flag support - Fix error message to show both '0' and '1' as valid values - Remove trailing space in colon-separated format output - Add test cases for -p and -p 1 flags --- cmd/sqlcmd/sqlcmd.go | 3 ++- cmd/sqlcmd/sqlcmd_test.go | 6 ++++++ pkg/sqlcmd/sqlcmd.go | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/cmd/sqlcmd/sqlcmd.go b/cmd/sqlcmd/sqlcmd.go index 40964cdf..db31cbc2 100644 --- a/cmd/sqlcmd/sqlcmd.go +++ b/cmd/sqlcmd/sqlcmd.go @@ -332,6 +332,7 @@ func checkDefaultValue(args []string, i int) (val string) { 'k': "0", 'L': "|", // | is the sentinel for no value since users are unlikely to use it. It's "reserved" in most shells 'X': "0", + 'p': "0", } if isFlag(args[i]) && len(args[i]) == 2 && (len(args) == i+1 || args[i+1][0] == '-') { if v, ok := flags[rune(args[i][1])]; ok { @@ -552,7 +553,7 @@ func normalizeFlags(cmd *cobra.Command) error { case "0", "1": return pflag.NormalizedName(name) default: - err = invalidParameterError("-p", v, "1") + err = invalidParameterError("-p", v, "0", "1") return pflag.NormalizedName("") } } diff --git a/cmd/sqlcmd/sqlcmd_test.go b/cmd/sqlcmd/sqlcmd_test.go index 511816b2..7805bf5d 100644 --- a/cmd/sqlcmd/sqlcmd_test.go +++ b/cmd/sqlcmd/sqlcmd_test.go @@ -99,6 +99,12 @@ func TestValidCommandLineToArgsConversion(t *testing.T) { {[]string{"-k", "-X", "-r", "-z", "something"}, func(args SQLCmdArguments) bool { return args.warnOnBlockedCmd() && !args.useEnvVars() && args.getControlCharacterBehavior() == sqlcmd.ControlRemove && *args.ErrorsToStderr == 0 && args.ChangePassword == "something" }}, + {[]string{"-p"}, func(args SQLCmdArguments) bool { + return args.PrintStatistics != nil && *args.PrintStatistics == 0 + }}, + {[]string{"-p", "1"}, func(args SQLCmdArguments) bool { + return args.PrintStatistics != nil && *args.PrintStatistics == 1 + }}, {[]string{"-N"}, func(args SQLCmdArguments) bool { return args.EncryptConnection == "true" }}, diff --git a/pkg/sqlcmd/sqlcmd.go b/pkg/sqlcmd/sqlcmd.go index 7a601e3e..66a52e78 100644 --- a/pkg/sqlcmd/sqlcmd.go +++ b/pkg/sqlcmd/sqlcmd.go @@ -612,7 +612,7 @@ func (s *Sqlcmd) printStatistics(elapsedMs int64, numBatches int) { if *s.PrintStatistics == 1 { // Colon-separated format: n:x:t1:t2:t3 // packetSize:numBatches:totalTime:avgTime:batchesPerSec - fmt.Fprintf(out, "\n%d:%d:%d:%.2f:%.2f \n", packetSize, numBatches, elapsedMs, avgTime, batchesPerSec) + fmt.Fprintf(out, "\n%d:%d:%d:%.2f:%.2f\n", packetSize, numBatches, elapsedMs, avgTime, batchesPerSec) } else { // Standard format fmt.Fprintf(out, "\nNetwork packet size (bytes): %d\n", packetSize)