From e4621c77f0b9f18cd0d1467e6b9ae34f86f1a02e Mon Sep 17 00:00:00 2001 From: David Levy Date: Sat, 24 Jan 2026 20:45:11 -0600 Subject: [PATCH 1/2] Add -p[1] performance statistics flag Implements the -p[1] flag for printing performance statistics after each result set: - -p or -p0: Human-readable format with network packet size, transaction count, and timing - -p1: Colon-separated format for spreadsheet/script processing Statistics include: - Network packet size (bytes) - Transaction count - Clock time: total, average, and transactions per second Fixes compatibility with ODBC sqlcmd -p flag. --- cmd/sqlcmd/sqlcmd.go | 16 ++++++++++ cmd/sqlcmd/sqlcmd_test.go | 11 +++++++ pkg/sqlcmd/sqlcmd.go | 63 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/cmd/sqlcmd/sqlcmd.go b/cmd/sqlcmd/sqlcmd.go index ea655b47..972a82f5 100644 --- a/cmd/sqlcmd/sqlcmd.go +++ b/cmd/sqlcmd/sqlcmd.go @@ -82,6 +82,9 @@ type SQLCmdArguments struct { ChangePassword string ChangePasswordAndExit string TraceFile string + // PrintStatistics prints performance statistics after each batch + // nil = disabled, 0 = human-readable, 1 = colon-separated + PrintStatistics *int // Keep Help at the end of the list Help bool } @@ -126,6 +129,7 @@ const ( disableCmdAndWarn = "disable-cmd-and-warn" listServers = "list-servers" removeControlCharacters = "remove-control-characters" + printStatistics = "print-statistics" ) func encryptConnectionAllowsTLS(value string) bool { @@ -330,6 +334,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 { @@ -393,6 +398,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) { @@ -474,6 +480,7 @@ func setFlags(rootCmd *cobra.Command, args *SQLCmdArguments) { _ = rootCmd.Flags().BoolP("enable-quoted-identifiers", "I", true, localizer.Sprintf("Provided for backward compatibility. Quoted identifiers are always enabled")) _ = 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().IntP(printStatistics, "p", -1, localizer.Sprintf("%s Print performance statistics for every result set. Pass 1 to output in colon-separated format", "-p[1]")) rootCmd.Flags().BoolVarP(&args.EchoInput, "echo-input", "e", false, localizer.Sprintf("Echo input")) 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")) @@ -543,6 +550,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, "0", "1") + return pflag.NormalizedName("") + } } return pflag.NormalizedName(name) @@ -829,6 +844,7 @@ func run(vars *sqlcmd.Variables, args *SQLCmdArguments) (int, error) { s.Connect = &connectConfig s.Format = sqlcmd.NewSQLCmdDefaultFormatter(args.TrimSpaces, args.getControlCharacterBehavior()) + s.PrintStatistics = args.PrintStatistics if args.OutputFile != "" { err = s.RunCommand(s.Cmd["OUT"], []string{args.OutputFile}) if err != nil { diff --git a/cmd/sqlcmd/sqlcmd_test.go b/cmd/sqlcmd/sqlcmd_test.go index 511816b2..9f7205fb 100644 --- a/cmd/sqlcmd/sqlcmd_test.go +++ b/cmd/sqlcmd/sqlcmd_test.go @@ -123,6 +123,16 @@ func TestValidCommandLineToArgsConversion(t *testing.T) { {[]string{"-N", "true", "-J", "/path/to/cert2.pem"}, func(args SQLCmdArguments) bool { return args.EncryptConnection == "true" && args.ServerCertificate == "/path/to/cert2.pem" }}, + // Test -p flag for performance statistics + {[]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{"--print-statistics", "0"}, func(args SQLCmdArguments) bool { + return args.PrintStatistics != nil && *args.PrintStatistics == 0 + }}, } for _, test := range commands { @@ -178,6 +188,7 @@ func TestInvalidCommandLine(t *testing.T) { {[]string{"-N", "optional", "-J", "/path/to/cert.pem"}, "The -J parameter requires encryption to be enabled (-N true, -N mandatory, or -N strict)."}, {[]string{"-N", "disable", "-J", "/path/to/cert.pem"}, "The -J parameter requires encryption to be enabled (-N true, -N mandatory, or -N strict)."}, {[]string{"-N", "strict", "-F", "myserver.domain.com", "-J", "/path/to/cert.pem"}, "The -F and the -J options are mutually exclusive."}, + {[]string{"-p", "2"}, "'-p 2': Unexpected argument. Argument value has to be one of [0 1]."}, } for _, test := range commands { diff --git a/pkg/sqlcmd/sqlcmd.go b/pkg/sqlcmd/sqlcmd.go index 5e572a94..e5abbfb0 100644 --- a/pkg/sqlcmd/sqlcmd.go +++ b/pkg/sqlcmd/sqlcmd.go @@ -88,6 +88,17 @@ type Sqlcmd struct { EchoInput bool colorizer color.Colorizer termchan chan os.Signal + // PrintStatistics controls whether performance statistics are printed after each batch + // nil = disabled, 0 = human-readable format, 1 = colon-separated format + PrintStatistics *int + // stats tracks cumulative statistics for the session + stats *SessionStats +} + +// SessionStats tracks cumulative performance statistics for a sqlcmd session +type SessionStats struct { + TotalTransactions int + TotalTimeMs float64 } // New creates a new Sqlcmd instance. @@ -420,6 +431,9 @@ func (s *Sqlcmd) getRunnableQuery(q string) string { // -101: No rows found // -102: Conversion error occurred when selecting return value func (s *Sqlcmd) runQuery(query string) (int, error) { + // Start timing for statistics + startTime := time.Now() + retcode := -101 s.Format.BeginBatch(query, s.vars, s.GetOutput(), s.GetError()) ctx := context.Background() @@ -508,6 +522,13 @@ func (s *Sqlcmd) runQuery(query string) (int, error) { } } s.Format.EndBatch() + + // Print statistics if enabled + if s.PrintStatistics != nil { + elapsed := time.Since(startTime) + s.printStatistics(elapsed) + } + return retcode, qe } @@ -552,6 +573,48 @@ func (s *Sqlcmd) handleError(retcode *int, err error) error { return nil } +// printStatistics prints performance statistics for the query +func (s *Sqlcmd) printStatistics(elapsed time.Duration) { + if s.stats == nil { + s.stats = &SessionStats{} + } + + // Update cumulative statistics + s.stats.TotalTransactions++ + elapsedMs := float64(elapsed.Milliseconds()) + s.stats.TotalTimeMs += elapsedMs + + // Calculate statistics + avgMs := s.stats.TotalTimeMs / float64(s.stats.TotalTransactions) + var xactsPerSec float64 + if s.stats.TotalTimeMs > 0 { + xactsPerSec = float64(s.stats.TotalTransactions) / (s.stats.TotalTimeMs / 1000.0) + } + + // Get packet size from connection settings + packetSize := s.Connect.PacketSize + if packetSize == 0 { + packetSize = 4096 // default + } + + out := s.GetOutput() + + if *s.PrintStatistics == 1 { + // Colon-separated format for spreadsheet/script processing + _, _ = out.Write([]byte(localizer.Sprintf("Network packet size (bytes):%d%s", packetSize, SqlcmdEol))) + _, _ = out.Write([]byte(localizer.Sprintf("%d xact(s):%s", s.stats.TotalTransactions, SqlcmdEol))) + _, _ = out.Write([]byte(localizer.Sprintf("Clock Time (ms.): total:%d:avg:%.2f:(%.4f xacts per sec.)%s", + int(s.stats.TotalTimeMs), avgMs, xactsPerSec, SqlcmdEol))) + } else { + // Human-readable format + _, _ = out.Write([]byte(SqlcmdEol)) + _, _ = out.Write([]byte(localizer.Sprintf("Network packet size (bytes): %d%s", packetSize, SqlcmdEol))) + _, _ = out.Write([]byte(localizer.Sprintf("%d xact(s):%s", s.stats.TotalTransactions, SqlcmdEol))) + _, _ = out.Write([]byte(localizer.Sprintf("Clock Time (ms.): total %d avg %.2f (%.4f xacts per sec.)%s", + int(s.stats.TotalTimeMs), avgMs, xactsPerSec, SqlcmdEol))) + } +} + // Log attempts to write driver traces to the current output. It ignores errors func (s Sqlcmd) Log(_ context.Context, _ msdsn.Log, msg string) { _, _ = s.GetOutput().Write([]byte("DRIVER:" + msg)) From 839767e760bc9d04d7e449b76e34bdcbde6c15db Mon Sep 17 00:00:00 2001 From: David Levy Date: Sat, 24 Jan 2026 20:47:53 -0600 Subject: [PATCH 2/2] Add -p flag documentation to README --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index fe26e192..aef705cc 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,21 @@ client_interface_name go-mssqldb program_name sqlcmd ``` +- The `-p[1]` flag prints performance statistics after each result set. Use `-p` for human-readable format or `-p1` for colon-separated format suitable for scripts. + +``` +1> select 1 +2> go + +----------- + 1 + +(1 row affected) +Network packet size (bytes): 4096 +1 xact(s): +Clock Time (ms.): total 15 avg 15 (66.67 xacts per sec.) +``` + - `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`