From 601948fa2146dd66ae41d50862da56f59c35aee1 Mon Sep 17 00:00:00 2001 From: David Levy Date: Thu, 5 Feb 2026 12:30:56 -0600 Subject: [PATCH 01/17] feat: implement :serverlist and :help interactive commands - :serverlist queries SQL Browser service (UDP 1434) to discover instances - :help displays available sqlcmd commands - Refactored server listing logic to pkg/sqlcmd/serverlist.go for reuse by both -L flag and :serverlist command --- README.md | 35 + cmd/sqlcmd/sqlcmd.go | 79 +- pkg/sqlcmd/commands.go | 1356 +++++++++++++++++---------------- pkg/sqlcmd/commands_test.go | 26 + pkg/sqlcmd/serverlist.go | 121 +++ pkg/sqlcmd/serverlist_test.go | 90 +++ 6 files changed, 985 insertions(+), 722 deletions(-) create mode 100644 pkg/sqlcmd/serverlist.go create mode 100644 pkg/sqlcmd/serverlist_test.go diff --git a/README.md b/README.md index e4a1e35d..3f773351 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,41 @@ 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. +- `:serverlist` lists local SQL Server instances discovered via the SQL Server Browser service (UDP port 1434). The command queries the SQL Browser service and displays the server name and instance name for each discovered instance. If no instances are found or the Browser service is not running, no output is produced. Non-timeout errors are printed to stderr. + +``` +1> :serverlist +MYSERVER\SQL2019 +MYSERVER\SQL2022 +``` + +#### Using :serverlist in batch scripts + +When automating server discovery, you can capture the output and check for errors: + +```batch +@echo off +REM Discover local SQL Server instances and connect to the first one +sqlcmd -Q ":serverlist" 2>nul > servers.txt +if %errorlevel% neq 0 ( + echo Error discovering servers + exit /b 1 +) +for /f "tokens=1" %%s in (servers.txt) do ( + echo Connecting to %%s... + sqlcmd -S %%s -Q "SELECT @@SERVERNAME" + goto :done +) +echo No SQL Server instances found +:done +``` + +To capture stderr separately (for error logging): +```batch +sqlcmd -Q ":serverlist" 2>errors.log > servers.txt +if exist errors.log if not "%%~z errors.log"=="0" type errors.log +``` ``` 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 7d69b24b..cf142d94 100644 --- a/cmd/sqlcmd/sqlcmd.go +++ b/cmd/sqlcmd/sqlcmd.go @@ -5,20 +5,16 @@ package sqlcmd import ( - "context" "errors" "fmt" - "net" "os" "regexp" "runtime/trace" "strconv" "strings" - "time" mssql "github.com/microsoft/go-mssqldb" "github.com/microsoft/go-mssqldb/azuread" - "github.com/microsoft/go-mssqldb/msdsn" "github.com/microsoft/go-sqlcmd/internal/localizer" "github.com/microsoft/go-sqlcmd/pkg/console" "github.com/microsoft/go-sqlcmd/pkg/sqlcmd" @@ -236,7 +232,7 @@ func Execute(version string) { fmt.Println() fmt.Println(localizer.Sprintf("Servers:")) } - listLocalServers() + sqlcmd.ListLocalServers(os.Stdout) os.Exit(0) } if len(argss) > 0 { @@ -915,76 +911,3 @@ func run(vars *sqlcmd.Variables, args *SQLCmdArguments) (int, error) { s.SetError(nil) return s.Exitcode, err } - -func listLocalServers() { - bmsg := []byte{byte(msdsn.BrowserAllInstances)} - resp := make([]byte, 16*1024-1) - dialer := &net.Dialer{} - ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) - defer cancel() - conn, err := dialer.DialContext(ctx, "udp", ":1434") - // silently ignore failures to connect, same as ODBC - if err != nil { - return - } - defer conn.Close() - dl, _ := ctx.Deadline() - _ = conn.SetDeadline(dl) - _, err = conn.Write(bmsg) - if err != nil { - if !errors.Is(err, os.ErrDeadlineExceeded) { - fmt.Println(err) - } - return - } - read, err := conn.Read(resp) - if err != nil { - if !errors.Is(err, os.ErrDeadlineExceeded) { - fmt.Println(err) - } - return - } - - data := parseInstances(resp[:read]) - instances := make([]string, 0, len(data)) - for s := range data { - if s == "MSSQLSERVER" { - - instances = append(instances, "(local)", data[s]["ServerName"]) - } else { - instances = append(instances, fmt.Sprintf(`%s\%s`, data[s]["ServerName"], s)) - } - } - for _, s := range instances { - fmt.Println(" ", s) - } -} - -func parseInstances(msg []byte) msdsn.BrowserData { - results := msdsn.BrowserData{} - if len(msg) > 3 && msg[0] == 5 { - out_s := string(msg[3:]) - tokens := strings.Split(out_s, ";") - instdict := map[string]string{} - got_name := false - var name string - for _, token := range tokens { - if got_name { - instdict[name] = token - got_name = false - } else { - name = token - if len(name) == 0 { - if len(instdict) == 0 { - break - } - results[strings.ToUpper(instdict["InstanceName"])] = instdict - instdict = map[string]string{} - continue - } - got_name = true - } - } - } - return results -} diff --git a/pkg/sqlcmd/commands.go b/pkg/sqlcmd/commands.go index 66dd1dba..548632e6 100644 --- a/pkg/sqlcmd/commands.go +++ b/pkg/sqlcmd/commands.go @@ -1,644 +1,712 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -package sqlcmd - -import ( - "flag" - "fmt" - "os" - "regexp" - "sort" - "strconv" - "strings" - - "github.com/microsoft/go-sqlcmd/internal/color" - "golang.org/x/text/encoding/unicode" - "golang.org/x/text/transform" -) - -// Command defines a sqlcmd action which can be intermixed with the SQL batch -// Commands for sqlcmd are defined at https://docs.microsoft.com/sql/tools/sqlcmd-utility#sqlcmd-commands -type Command struct { - // regex must include at least one group if it has parameters - // Will be matched using FindStringSubmatch - regex *regexp.Regexp - // The function that implements the command. Third parameter is the line number - action func(*Sqlcmd, []string, uint) error - // Name of the command - name string - // whether the command is a system command - isSystem bool -} - -// Commands is the set of sqlcmd command implementations -type Commands map[string]*Command - -func newCommands() Commands { - // Commands is the set of Command implementations - return map[string]*Command{ - "EXIT": { - regex: regexp.MustCompile(`(?im)^[\t ]*?:?EXIT([\( \t]+.*\)*$|$)`), - action: exitCommand, - name: "EXIT", - }, - "QUIT": { - regex: regexp.MustCompile(`(?im)^[\t ]*?:?QUIT(?:[ \t]+(.*$)|$)`), - action: quitCommand, - name: "QUIT", - }, - "GO": { - regex: regexp.MustCompile(batchTerminatorRegex("GO")), - action: goCommand, - name: "GO", - }, - "OUT": { - regex: regexp.MustCompile(`(?im)^[ \t]*:OUT(?:[ \t]+(.*$)|$)`), - action: outCommand, - name: "OUT", - }, - "ERROR": { - regex: regexp.MustCompile(`(?im)^[ \t]*:ERROR(?:[ \t]+(.*$)|$)`), - action: errorCommand, - name: "ERROR", - }, "READFILE": { - regex: regexp.MustCompile(`(?im)^[ \t]*:R(?:[ \t]+(.*$)|$)`), - action: readFileCommand, - name: "READFILE", - }, - "SETVAR": { - regex: regexp.MustCompile(`(?im)^[ \t]*:SETVAR(?:[ \t]+(.*$)|$)`), - action: setVarCommand, - name: "SETVAR", - }, - "LISTVAR": { - regex: regexp.MustCompile(`(?im)^[\t ]*?:LISTVAR(?:[ \t]+(.*$)|$)`), - action: listVarCommand, - name: "LISTVAR", - }, - "RESET": { - regex: regexp.MustCompile(`(?im)^[ \t]*?:?RESET(?:[ \t]+(.*$)|$)`), - action: resetCommand, - name: "RESET", - }, - "LIST": { - regex: regexp.MustCompile(`(?im)^[ \t]*:LIST(?:[ \t]+(.*$)|$)`), - action: listCommand, - name: "LIST", - }, - "CONNECT": { - regex: regexp.MustCompile(`(?im)^[ \t]*:CONNECT(?:[ \t]+(.*$)|$)`), - action: connectCommand, - name: "CONNECT", - }, - "EXEC": { - regex: regexp.MustCompile(`(?im)^[ \t]*?:?!!(.*$)`), - action: execCommand, - name: "EXEC", - isSystem: true, - }, - "EDIT": { - regex: regexp.MustCompile(`(?im)^[\t ]*?:?ED(?:[ \t]+(.*$)|$)`), - action: editCommand, - name: "EDIT", - isSystem: true, - }, - "ONERROR": { - regex: regexp.MustCompile(`(?im)^[\t ]*?:?ON ERROR(?:[ \t]+(.*$)|$)`), - action: onerrorCommand, - name: "ONERROR", - }, - "XML": { - regex: regexp.MustCompile(`(?im)^[\t ]*?:XML(?:[ \t]+(.*$)|$)`), - action: xmlCommand, - name: "XML", - }, - } -} - -// DisableSysCommands disables the ED and :!! commands. -// When exitOnCall is true, running those commands will exit the process. -func (c Commands) DisableSysCommands(exitOnCall bool) { - f := warnDisabled - if exitOnCall { - f = errorDisabled - } - for _, cmd := range c { - if cmd.isSystem { - cmd.action = f - } - } -} - -func (c Commands) matchCommand(line string) (*Command, []string) { - for _, cmd := range c { - matchedCommand := cmd.regex.FindStringSubmatch(line) - if matchedCommand != nil { - return cmd, removeComments(matchedCommand[1:]) - } - } - return nil, nil -} - -func removeComments(args []string) []string { - var pos int - quote := false - for i := range args { - pos, quote = commentStart([]rune(args[i]), quote) - if pos > -1 { - out := make([]string, i+1) - if i > 0 { - copy(out, args[:i]) - } - out[i] = args[i][:pos] - return out - } - } - return args -} - -func commentStart(arg []rune, quote bool) (int, bool) { - var i int - space := true - for ; i < len(arg); i++ { - c, next := arg[i], grab(arg, i+1, len(arg)) - switch { - case quote && c == '"' && next != '"': - quote = false - case quote && c == '"' && next == '"': - i++ - case c == '\t' || c == ' ': - space = true - // Note we assume none of the regexes would split arguments on non-whitespace boundaries such that "text -- comment" would get split into "text -" and "- comment" - case !quote && space && c == '-' && next == '-': - return i, false - case !quote && c == '"': - quote = true - default: - space = false - } - } - return -1, quote -} - -func warnDisabled(s *Sqlcmd, args []string, line uint) error { - s.WriteError(s.GetError(), ErrCommandsDisabled) - return nil -} - -func errorDisabled(s *Sqlcmd, args []string, line uint) error { - s.WriteError(s.GetError(), ErrCommandsDisabled) - s.Exitcode = 1 - return ErrExitRequested -} - -func batchTerminatorRegex(terminator string) string { - return fmt.Sprintf(`(?im)^[\t ]*?%s(?:[ ]+(.*$)|$)`, regexp.QuoteMeta(terminator)) -} - -// SetBatchTerminator attempts to set the batch terminator to the given value -// Returns an error if the new value is not usable in the regex -func (c Commands) SetBatchTerminator(terminator string) error { - cmd := c["GO"] - regex, err := regexp.Compile(batchTerminatorRegex(terminator)) - if err != nil { - return err - } - cmd.regex = regex - return nil -} - -// exitCommand has 3 modes. -// With no (), it just exits without running any query -// With () it runs whatever batch is in the buffer then exits -// With any text between () it runs the text as a query then exits -func exitCommand(s *Sqlcmd, args []string, line uint) error { - if len(args) == 0 { - return ErrExitRequested - } - params := strings.TrimSpace(args[0]) - if params == "" { - return ErrExitRequested - } - if !strings.HasPrefix(params, "(") || !strings.HasSuffix(params, ")") { - return InvalidCommandError("EXIT", line) - } - // First we save the current batch - query1 := s.batch.String() - if len(query1) > 0 { - query1 = s.getRunnableQuery(query1) - } - // Now parse the params of EXIT as a batch without commands - cmd := s.batch.cmd - s.batch.cmd = nil - defer func() { - s.batch.cmd = cmd - }() - query2 := strings.TrimSpace(params[1 : len(params)-1]) - if len(query2) > 0 { - s.batch.Reset([]rune(query2)) - _, _, err := s.batch.Next() - if err != nil { - return err - } - query2 = s.batch.String() - if len(query2) > 0 { - query2 = s.getRunnableQuery(query2) - } - } - - if len(query1) > 0 || len(query2) > 0 { - query := query1 + SqlcmdEol + query2 - s.Exitcode, _ = s.runQuery(query) - } - return ErrExitRequested -} - -// quitCommand immediately exits the program without running any more batches -func quitCommand(s *Sqlcmd, args []string, line uint) error { - if args != nil && strings.TrimSpace(args[0]) != "" { - return InvalidCommandError("QUIT", line) - } - return ErrExitRequested -} - -// goCommand runs the current batch the number of times specified -func goCommand(s *Sqlcmd, args []string, line uint) error { - // default to 1 execution - n := 1 - var err error - if len(args) > 0 { - cnt := strings.TrimSpace(args[0]) - if cnt != "" { - if cnt, err = resolveArgumentVariables(s, []rune(cnt), true); err != nil { - return err - } - _, err = fmt.Sscanf(cnt, "%d", &n) - } - } - if err != nil || n < 1 { - return InvalidCommandError("GO", line) - } - if s.EchoInput { - err = listCommand(s, []string{}, line) - } - if err != nil { - return InvalidCommandError("GO", line) - } - query := s.batch.String() - if query == "" { - return nil - } - query = s.getRunnableQuery(query) - for i := 0; i < n; i++ { - if retcode, err := s.runQuery(query); err != nil { - s.Exitcode = retcode - return err - } - } - s.batch.Reset(nil) - return nil -} - -// outCommand changes the output writer to use a file -func outCommand(s *Sqlcmd, args []string, line uint) error { - if len(args) == 0 || args[0] == "" { - return InvalidCommandError("OUT", 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) - 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) - } - } - 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 - } - 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]) - } - s.SetError(o) - } - return nil -} - -func readFileCommand(s *Sqlcmd, args []string, line uint) error { - if args == nil || len(args) != 1 { - return InvalidCommandError(":R", line) - } - fileName, _ := resolveArgumentVariables(s, []rune(args[0]), false) - return s.IncludeFile(fileName, false) -} - -// setVarCommand parses a variable setting and applies it to the current Sqlcmd variables -func setVarCommand(s *Sqlcmd, args []string, line uint) error { - if args == nil || len(args) != 1 || args[0] == "" { - return InvalidCommandError(":SETVAR", line) - } - - varname := args[0] - val := "" - // The prior incarnation of sqlcmd doesn't require a space between the variable name and its value - // in some very unexpected cases. This version will require the space. - sp := strings.IndexRune(args[0], ' ') - if sp > -1 { - val = strings.TrimSpace(varname[sp:]) - varname = varname[:sp] - } - if err := s.vars.Setvar(varname, val); err != nil { - switch e := err.(type) { - case *VariableError: - return e - default: - return InvalidCommandError(":SETVAR", line) - } - } - return nil -} - -// listVarCommand prints the set of Sqlcmd scripting variables. -// Builtin values are printed first, followed by user-set values in sorted order. -func listVarCommand(s *Sqlcmd, args []string, line uint) error { - if args != nil && strings.TrimSpace(args[0]) != "" { - return InvalidCommandError("LISTVAR", line) - } - - vars := s.vars.All() - keys := make([]string, 0, len(vars)) - for k := range vars { - if !contains(builtinVariables, k) { - keys = append(keys, k) - } - } - sort.Strings(keys) - keys = append(builtinVariables, keys...) - for _, k := range keys { - fmt.Fprintf(s.GetOutput(), `%s = "%s"%s`, k, vars[k], SqlcmdEol) - } - return nil -} - -// resetCommand resets the statement cache -func resetCommand(s *Sqlcmd, args []string, line uint) error { - if s.batch != nil { - s.batch.Reset(nil) - } - - return nil -} - -// listCommand displays statements currently in the statement cache -func listCommand(s *Sqlcmd, args []string, line uint) (err error) { - cmd := "" - if args != nil { - if len(args) > 0 { - cmd = strings.ToLower(strings.TrimSpace(args[0])) - if len(args) > 1 || (cmd != "color" && cmd != "") { - return InvalidCommandError("LIST", line) - } - } - } - output := s.GetOutput() - if cmd == "color" { - sample := "select 'literal' as literal, 100 as number from [sys].[tables]" - clr := color.TextTypeTSql - if s.Format.IsXmlMode() { - sample = `value` - clr = color.TextTypeXml - } - // ignoring errors since it's not critical output - for _, style := range s.colorizer.Styles() { - _, _ = output.Write([]byte(style + ": ")) - _ = s.colorizer.Write(output, sample, style, clr) - _, _ = output.Write([]byte(SqlcmdEol)) - } - return - } - if s.batch == nil || s.batch.String() == "" { - return - } - - if err = s.colorizer.Write(output, s.batch.String(), s.vars.ColorScheme(), color.TextTypeTSql); err == nil { - _, err = output.Write([]byte(SqlcmdEol)) - } - - return -} - -func connectCommand(s *Sqlcmd, args []string, line uint) error { - if len(args) == 0 { - return InvalidCommandError("CONNECT", line) - } - - commandArgs := strings.Fields(args[0]) - - // Parse flags - flags := flag.NewFlagSet("connect", flag.ContinueOnError) - database := flags.String("D", "", "database name") - username := flags.String("U", "", "user name") - password := flags.String("P", "", "password") - loginTimeout := flags.String("l", "", "login timeout") - authenticationMethod := flags.String("G", "", "authentication method") - - err := flags.Parse(commandArgs[1:]) - //err := flags.Parse(args[1:]) - if err != nil { - return InvalidCommandError("CONNECT", line) - } - - connect := *s.Connect - connect.UserName, _ = resolveArgumentVariables(s, []rune(*username), false) - connect.Password, _ = resolveArgumentVariables(s, []rune(*password), false) - connect.Database, _ = resolveArgumentVariables(s, []rune(*database), false) - - timeout, _ := resolveArgumentVariables(s, []rune(*loginTimeout), false) - if timeout != "" { - if timeoutSeconds, err := strconv.ParseInt(timeout, 10, 32); err == nil { - if timeoutSeconds < 0 { - return InvalidCommandError("CONNECT", line) - } - connect.LoginTimeoutSeconds = int(timeoutSeconds) - } - } - - connect.AuthenticationMethod = *authenticationMethod - - // Set server name as the first positional argument - if len(commandArgs) > 0 { - connect.ServerName, _ = resolveArgumentVariables(s, []rune(commandArgs[0]), false) - } - - // If no user name is provided we switch to integrated auth - _ = s.ConnectDb(&connect, s.lineIo == nil) - - // ConnectDb prints connection errors already, and failure to connect is not fatal even with -b option - return nil -} - -func execCommand(s *Sqlcmd, args []string, line uint) error { - if len(args) == 0 { - return InvalidCommandError("EXEC", line) - } - cmdLine := strings.TrimSpace(args[0]) - if cmdLine == "" { - return InvalidCommandError("EXEC", line) - } - if cmdLine, err := resolveArgumentVariables(s, []rune(cmdLine), true); err != nil { - return err - } else { - cmd := sysCommand(cmdLine) - cmd.Stderr = s.GetError() - cmd.Stdout = s.GetOutput() - _ = cmd.Run() - } - return nil -} - -func editCommand(s *Sqlcmd, args []string, line uint) error { - if args != nil && strings.TrimSpace(args[0]) != "" { - return InvalidCommandError("ED", line) - } - file, err := os.CreateTemp("", "sq*.sql") - if err != nil { - return err - } - fileName := file.Name() - defer os.Remove(fileName) - text := s.batch.String() - if s.batch.State() == "-" { - text = fmt.Sprintf("%s%s", text, SqlcmdEol) - } - _, err = file.WriteString(text) - if err != nil { - return err - } - file.Close() - cmd := sysCommand(s.vars.TextEditor() + " " + `"` + fileName + `"`) - cmd.Stderr = s.GetError() - cmd.Stdout = s.GetOutput() - err = cmd.Run() - if err != nil { - return err - } - wasEcho := s.echoFileLines - s.echoFileLines = true - s.batch.Reset(nil) - _ = s.IncludeFile(fileName, false) - s.echoFileLines = wasEcho - return nil -} - -func onerrorCommand(s *Sqlcmd, args []string, line uint) error { - if len(args) == 0 || args[0] == "" { - return InvalidCommandError("ON ERROR", line) - } - params := strings.TrimSpace(args[0]) - - if strings.EqualFold(strings.ToLower(params), "exit") { - s.Connect.ExitOnError = true - } else if strings.EqualFold(strings.ToLower(params), "ignore") { - s.Connect.IgnoreError = true - s.Connect.ExitOnError = false - } else { - return InvalidCommandError("ON ERROR", line) - } - return nil -} - -func xmlCommand(s *Sqlcmd, args []string, line uint) error { - if len(args) != 1 || args[0] == "" { - return InvalidCommandError("XML", line) - } - params := strings.TrimSpace(args[0]) - // "OFF" and "ON" are documented as the allowed values. - // ODBC sqlcmd treats any value other than "ON" the same as "OFF". - // So we will too. - if strings.EqualFold(params, "on") { - s.Format.XmlMode(true) - } else { - s.Format.XmlMode(false) - } - return nil -} - -func resolveArgumentVariables(s *Sqlcmd, arg []rune, failOnUnresolved bool) (string, error) { - var b *strings.Builder - end := len(arg) - for i := 0; i < end && !s.Connect.DisableVariableSubstitution; { - c, next := arg[i], grab(arg, i+1, end) - switch { - case c == '$' && next == '(': - vl, ok := readVariableReference(arg, i+2, end) - if ok { - varName := string(arg[i+2 : vl]) - val, ok := s.resolveVariable(varName) - if ok { - if b == nil { - b = new(strings.Builder) - b.Grow(len(arg)) - b.WriteString(string(arg[0:i])) - } - b.WriteString(val) - } else { - if failOnUnresolved { - return "", UndefinedVariable(varName) - } - s.WriteError(s.GetError(), UndefinedVariable(varName)) - if b != nil { - b.WriteString(string(arg[i : vl+1])) - } - } - i += ((vl - i) + 1) - } else { - if b != nil { - b.WriteString("$(") - } - i += 2 - } - default: - if b != nil { - b.WriteRune(c) - } - i++ - } - } - if b == nil { - return string(arg), nil - } - return b.String(), nil -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package sqlcmd + +import ( + "flag" + "fmt" + "os" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/microsoft/go-sqlcmd/internal/color" + "golang.org/x/text/encoding/unicode" + "golang.org/x/text/transform" +) + +// Command defines a sqlcmd action which can be intermixed with the SQL batch +// Commands for sqlcmd are defined at https://docs.microsoft.com/sql/tools/sqlcmd-utility#sqlcmd-commands +type Command struct { + // regex must include at least one group if it has parameters + // Will be matched using FindStringSubmatch + regex *regexp.Regexp + // The function that implements the command. Third parameter is the line number + action func(*Sqlcmd, []string, uint) error + // Name of the command + name string + // whether the command is a system command + isSystem bool +} + +// Commands is the set of sqlcmd command implementations +type Commands map[string]*Command + +func newCommands() Commands { + // Commands is the set of Command implementations + return map[string]*Command{ + "EXIT": { + regex: regexp.MustCompile(`(?im)^[\t ]*?:?EXIT([\( \t]+.*\)*$|$)`), + action: exitCommand, + name: "EXIT", + }, + "QUIT": { + regex: regexp.MustCompile(`(?im)^[\t ]*?:?QUIT(?:[ \t]+(.*$)|$)`), + action: quitCommand, + name: "QUIT", + }, + "GO": { + regex: regexp.MustCompile(batchTerminatorRegex("GO")), + action: goCommand, + name: "GO", + }, + "OUT": { + regex: regexp.MustCompile(`(?im)^[ \t]*:OUT(?:[ \t]+(.*$)|$)`), + action: outCommand, + name: "OUT", + }, + "ERROR": { + regex: regexp.MustCompile(`(?im)^[ \t]*:ERROR(?:[ \t]+(.*$)|$)`), + action: errorCommand, + name: "ERROR", + }, "READFILE": { + regex: regexp.MustCompile(`(?im)^[ \t]*:R(?:[ \t]+(.*$)|$)`), + action: readFileCommand, + name: "READFILE", + }, + "SETVAR": { + regex: regexp.MustCompile(`(?im)^[ \t]*:SETVAR(?:[ \t]+(.*$)|$)`), + action: setVarCommand, + name: "SETVAR", + }, + "LISTVAR": { + regex: regexp.MustCompile(`(?im)^[\t ]*?:LISTVAR(?:[ \t]+(.*$)|$)`), + action: listVarCommand, + name: "LISTVAR", + }, + "RESET": { + regex: regexp.MustCompile(`(?im)^[ \t]*?:?RESET(?:[ \t]+(.*$)|$)`), + action: resetCommand, + name: "RESET", + }, + "LIST": { + regex: regexp.MustCompile(`(?im)^[ \t]*:LIST(?:[ \t]+(.*$)|$)`), + action: listCommand, + name: "LIST", + }, + "CONNECT": { + regex: regexp.MustCompile(`(?im)^[ \t]*:CONNECT(?:[ \t]+(.*$)|$)`), + action: connectCommand, + name: "CONNECT", + }, + "EXEC": { + regex: regexp.MustCompile(`(?im)^[ \t]*?:?!!(.*$)`), + action: execCommand, + name: "EXEC", + isSystem: true, + }, + "EDIT": { + regex: regexp.MustCompile(`(?im)^[\t ]*?:?ED(?:[ \t]+(.*$)|$)`), + action: editCommand, + name: "EDIT", + isSystem: true, + }, + "ONERROR": { + regex: regexp.MustCompile(`(?im)^[\t ]*?:?ON ERROR(?:[ \t]+(.*$)|$)`), + action: onerrorCommand, + name: "ONERROR", + }, + "XML": { + regex: regexp.MustCompile(`(?im)^[\t ]*?:XML(?:[ \t]+(.*$)|$)`), + action: xmlCommand, + name: "XML", + }, + "HELP": { + regex: regexp.MustCompile(`(?im)^[ \t]*:HELP(?:[ \t]+(.*$)|$)`), + action: helpCommand, + name: "HELP", + }, + "SERVERLIST": { + regex: regexp.MustCompile(`(?im)^[ \t]*:SERVERLIST(?:[ \t]+(.*$)|$)`), + action: serverlistCommand, + name: "SERVERLIST", + }, + } +} + +// DisableSysCommands disables the ED and :!! commands. +// When exitOnCall is true, running those commands will exit the process. +func (c Commands) DisableSysCommands(exitOnCall bool) { + f := warnDisabled + if exitOnCall { + f = errorDisabled + } + for _, cmd := range c { + if cmd.isSystem { + cmd.action = f + } + } +} + +func (c Commands) matchCommand(line string) (*Command, []string) { + for _, cmd := range c { + matchedCommand := cmd.regex.FindStringSubmatch(line) + if matchedCommand != nil { + return cmd, removeComments(matchedCommand[1:]) + } + } + return nil, nil +} + +func removeComments(args []string) []string { + var pos int + quote := false + for i := range args { + pos, quote = commentStart([]rune(args[i]), quote) + if pos > -1 { + out := make([]string, i+1) + if i > 0 { + copy(out, args[:i]) + } + out[i] = args[i][:pos] + return out + } + } + return args +} + +func commentStart(arg []rune, quote bool) (int, bool) { + var i int + space := true + for ; i < len(arg); i++ { + c, next := arg[i], grab(arg, i+1, len(arg)) + switch { + case quote && c == '"' && next != '"': + quote = false + case quote && c == '"' && next == '"': + i++ + case c == '\t' || c == ' ': + space = true + // Note we assume none of the regexes would split arguments on non-whitespace boundaries such that "text -- comment" would get split into "text -" and "- comment" + case !quote && space && c == '-' && next == '-': + return i, false + case !quote && c == '"': + quote = true + default: + space = false + } + } + return -1, quote +} + +func warnDisabled(s *Sqlcmd, args []string, line uint) error { + s.WriteError(s.GetError(), ErrCommandsDisabled) + return nil +} + +func errorDisabled(s *Sqlcmd, args []string, line uint) error { + s.WriteError(s.GetError(), ErrCommandsDisabled) + s.Exitcode = 1 + return ErrExitRequested +} + +func batchTerminatorRegex(terminator string) string { + return fmt.Sprintf(`(?im)^[\t ]*?%s(?:[ ]+(.*$)|$)`, regexp.QuoteMeta(terminator)) +} + +// SetBatchTerminator attempts to set the batch terminator to the given value +// Returns an error if the new value is not usable in the regex +func (c Commands) SetBatchTerminator(terminator string) error { + cmd := c["GO"] + regex, err := regexp.Compile(batchTerminatorRegex(terminator)) + if err != nil { + return err + } + cmd.regex = regex + return nil +} + +// exitCommand has 3 modes. +// With no (), it just exits without running any query +// With () it runs whatever batch is in the buffer then exits +// With any text between () it runs the text as a query then exits +func exitCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) == 0 { + return ErrExitRequested + } + params := strings.TrimSpace(args[0]) + if params == "" { + return ErrExitRequested + } + if !strings.HasPrefix(params, "(") || !strings.HasSuffix(params, ")") { + return InvalidCommandError("EXIT", line) + } + // First we save the current batch + query1 := s.batch.String() + if len(query1) > 0 { + query1 = s.getRunnableQuery(query1) + } + // Now parse the params of EXIT as a batch without commands + cmd := s.batch.cmd + s.batch.cmd = nil + defer func() { + s.batch.cmd = cmd + }() + query2 := strings.TrimSpace(params[1 : len(params)-1]) + if len(query2) > 0 { + s.batch.Reset([]rune(query2)) + _, _, err := s.batch.Next() + if err != nil { + return err + } + query2 = s.batch.String() + if len(query2) > 0 { + query2 = s.getRunnableQuery(query2) + } + } + + if len(query1) > 0 || len(query2) > 0 { + query := query1 + SqlcmdEol + query2 + s.Exitcode, _ = s.runQuery(query) + } + return ErrExitRequested +} + +// quitCommand immediately exits the program without running any more batches +func quitCommand(s *Sqlcmd, args []string, line uint) error { + if args != nil && strings.TrimSpace(args[0]) != "" { + return InvalidCommandError("QUIT", line) + } + return ErrExitRequested +} + +// goCommand runs the current batch the number of times specified +func goCommand(s *Sqlcmd, args []string, line uint) error { + // default to 1 execution + n := 1 + var err error + if len(args) > 0 { + cnt := strings.TrimSpace(args[0]) + if cnt != "" { + if cnt, err = resolveArgumentVariables(s, []rune(cnt), true); err != nil { + return err + } + _, err = fmt.Sscanf(cnt, "%d", &n) + } + } + if err != nil || n < 1 { + return InvalidCommandError("GO", line) + } + if s.EchoInput { + err = listCommand(s, []string{}, line) + } + if err != nil { + return InvalidCommandError("GO", line) + } + query := s.batch.String() + if query == "" { + return nil + } + query = s.getRunnableQuery(query) + for i := 0; i < n; i++ { + if retcode, err := s.runQuery(query); err != nil { + s.Exitcode = retcode + return err + } + } + s.batch.Reset(nil) + return nil +} + +// outCommand changes the output writer to use a file +func outCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) == 0 || args[0] == "" { + return InvalidCommandError("OUT", 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) + 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) + } + } + 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 + } + 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]) + } + s.SetError(o) + } + return nil +} + +func readFileCommand(s *Sqlcmd, args []string, line uint) error { + if args == nil || len(args) != 1 { + return InvalidCommandError(":R", line) + } + fileName, _ := resolveArgumentVariables(s, []rune(args[0]), false) + return s.IncludeFile(fileName, false) +} + +// setVarCommand parses a variable setting and applies it to the current Sqlcmd variables +func setVarCommand(s *Sqlcmd, args []string, line uint) error { + if args == nil || len(args) != 1 || args[0] == "" { + return InvalidCommandError(":SETVAR", line) + } + + varname := args[0] + val := "" + // The prior incarnation of sqlcmd doesn't require a space between the variable name and its value + // in some very unexpected cases. This version will require the space. + sp := strings.IndexRune(args[0], ' ') + if sp > -1 { + val = strings.TrimSpace(varname[sp:]) + varname = varname[:sp] + } + if err := s.vars.Setvar(varname, val); err != nil { + switch e := err.(type) { + case *VariableError: + return e + default: + return InvalidCommandError(":SETVAR", line) + } + } + return nil +} + +// listVarCommand prints the set of Sqlcmd scripting variables. +// Builtin values are printed first, followed by user-set values in sorted order. +func listVarCommand(s *Sqlcmd, args []string, line uint) error { + if args != nil && strings.TrimSpace(args[0]) != "" { + return InvalidCommandError("LISTVAR", line) + } + + vars := s.vars.All() + keys := make([]string, 0, len(vars)) + for k := range vars { + if !contains(builtinVariables, k) { + keys = append(keys, k) + } + } + sort.Strings(keys) + keys = append(builtinVariables, keys...) + for _, k := range keys { + fmt.Fprintf(s.GetOutput(), `%s = "%s"%s`, k, vars[k], SqlcmdEol) + } + return nil +} + +// resetCommand resets the statement cache +func resetCommand(s *Sqlcmd, args []string, line uint) error { + if s.batch != nil { + s.batch.Reset(nil) + } + + return nil +} + +// listCommand displays statements currently in the statement cache +func listCommand(s *Sqlcmd, args []string, line uint) (err error) { + cmd := "" + if args != nil { + if len(args) > 0 { + cmd = strings.ToLower(strings.TrimSpace(args[0])) + if len(args) > 1 || (cmd != "color" && cmd != "") { + return InvalidCommandError("LIST", line) + } + } + } + output := s.GetOutput() + if cmd == "color" { + sample := "select 'literal' as literal, 100 as number from [sys].[tables]" + clr := color.TextTypeTSql + if s.Format.IsXmlMode() { + sample = `value` + clr = color.TextTypeXml + } + // ignoring errors since it's not critical output + for _, style := range s.colorizer.Styles() { + _, _ = output.Write([]byte(style + ": ")) + _ = s.colorizer.Write(output, sample, style, clr) + _, _ = output.Write([]byte(SqlcmdEol)) + } + return + } + if s.batch == nil || s.batch.String() == "" { + return + } + + if err = s.colorizer.Write(output, s.batch.String(), s.vars.ColorScheme(), color.TextTypeTSql); err == nil { + _, err = output.Write([]byte(SqlcmdEol)) + } + + return +} + +func connectCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) == 0 { + return InvalidCommandError("CONNECT", line) + } + + commandArgs := strings.Fields(args[0]) + + // Parse flags + flags := flag.NewFlagSet("connect", flag.ContinueOnError) + database := flags.String("D", "", "database name") + username := flags.String("U", "", "user name") + password := flags.String("P", "", "password") + loginTimeout := flags.String("l", "", "login timeout") + authenticationMethod := flags.String("G", "", "authentication method") + + err := flags.Parse(commandArgs[1:]) + //err := flags.Parse(args[1:]) + if err != nil { + return InvalidCommandError("CONNECT", line) + } + + connect := *s.Connect + connect.UserName, _ = resolveArgumentVariables(s, []rune(*username), false) + connect.Password, _ = resolveArgumentVariables(s, []rune(*password), false) + connect.Database, _ = resolveArgumentVariables(s, []rune(*database), false) + + timeout, _ := resolveArgumentVariables(s, []rune(*loginTimeout), false) + if timeout != "" { + if timeoutSeconds, err := strconv.ParseInt(timeout, 10, 32); err == nil { + if timeoutSeconds < 0 { + return InvalidCommandError("CONNECT", line) + } + connect.LoginTimeoutSeconds = int(timeoutSeconds) + } + } + + connect.AuthenticationMethod = *authenticationMethod + + // Set server name as the first positional argument + if len(commandArgs) > 0 { + connect.ServerName, _ = resolveArgumentVariables(s, []rune(commandArgs[0]), false) + } + + // If no user name is provided we switch to integrated auth + _ = s.ConnectDb(&connect, s.lineIo == nil) + + // ConnectDb prints connection errors already, and failure to connect is not fatal even with -b option + return nil +} + +func execCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) == 0 { + return InvalidCommandError("EXEC", line) + } + cmdLine := strings.TrimSpace(args[0]) + if cmdLine == "" { + return InvalidCommandError("EXEC", line) + } + if cmdLine, err := resolveArgumentVariables(s, []rune(cmdLine), true); err != nil { + return err + } else { + cmd := sysCommand(cmdLine) + cmd.Stderr = s.GetError() + cmd.Stdout = s.GetOutput() + _ = cmd.Run() + } + return nil +} + +func editCommand(s *Sqlcmd, args []string, line uint) error { + if args != nil && strings.TrimSpace(args[0]) != "" { + return InvalidCommandError("ED", line) + } + file, err := os.CreateTemp("", "sq*.sql") + if err != nil { + return err + } + fileName := file.Name() + defer os.Remove(fileName) + text := s.batch.String() + if s.batch.State() == "-" { + text = fmt.Sprintf("%s%s", text, SqlcmdEol) + } + _, err = file.WriteString(text) + if err != nil { + return err + } + file.Close() + cmd := sysCommand(s.vars.TextEditor() + " " + `"` + fileName + `"`) + cmd.Stderr = s.GetError() + cmd.Stdout = s.GetOutput() + err = cmd.Run() + if err != nil { + return err + } + wasEcho := s.echoFileLines + s.echoFileLines = true + s.batch.Reset(nil) + _ = s.IncludeFile(fileName, false) + s.echoFileLines = wasEcho + return nil +} + +func onerrorCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) == 0 || args[0] == "" { + return InvalidCommandError("ON ERROR", line) + } + params := strings.TrimSpace(args[0]) + + if strings.EqualFold(strings.ToLower(params), "exit") { + s.Connect.ExitOnError = true + } else if strings.EqualFold(strings.ToLower(params), "ignore") { + s.Connect.IgnoreError = true + s.Connect.ExitOnError = false + } else { + return InvalidCommandError("ON ERROR", line) + } + return nil +} + +func xmlCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) != 1 || args[0] == "" { + return InvalidCommandError("XML", line) + } + params := strings.TrimSpace(args[0]) + // "OFF" and "ON" are documented as the allowed values. + // ODBC sqlcmd treats any value other than "ON" the same as "OFF". + // So we will too. + if strings.EqualFold(params, "on") { + s.Format.XmlMode(true) + } else { + s.Format.XmlMode(false) + } + return nil +} + +// helpCommand displays the list of available sqlcmd commands +func helpCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) > 0 && strings.TrimSpace(args[0]) != "" { + return InvalidCommandError("HELP", line) + } + 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. +:quit + - Quits sqlcmd immediately. +:r + - Append file contents to the statement cache. +:reset + - Discards the statement cache. +:serverlist + - Lists local SQL Server instances. +: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 serverlistCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) > 0 && strings.TrimSpace(args[0]) != "" { + return InvalidCommandError("SERVERLIST", line) + } + ListLocalServers(s.GetOutput()) + return nil +} + +func resolveArgumentVariables(s *Sqlcmd, arg []rune, failOnUnresolved bool) (string, error) { + var b *strings.Builder + end := len(arg) + for i := 0; i < end && !s.Connect.DisableVariableSubstitution; { + c, next := arg[i], grab(arg, i+1, end) + switch { + case c == '$' && next == '(': + vl, ok := readVariableReference(arg, i+2, end) + if ok { + varName := string(arg[i+2 : vl]) + val, ok := s.resolveVariable(varName) + if ok { + if b == nil { + b = new(strings.Builder) + b.Grow(len(arg)) + b.WriteString(string(arg[0:i])) + } + b.WriteString(val) + } else { + if failOnUnresolved { + return "", UndefinedVariable(varName) + } + s.WriteError(s.GetError(), UndefinedVariable(varName)) + if b != nil { + b.WriteString(string(arg[i : vl+1])) + } + } + i += ((vl - i) + 1) + } else { + if b != nil { + b.WriteString("$(") + } + i += 2 + } + default: + if b != nil { + b.WriteRune(c) + } + i++ + } + } + if b == nil { + return string(arg), nil + } + return b.String(), nil +} diff --git a/pkg/sqlcmd/commands_test.go b/pkg/sqlcmd/commands_test.go index 6197aa3f..7895e307 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{""}}, + {`:SERVERLIST`, "SERVERLIST", []string{""}}, + {`:serverlist`, "SERVERLIST", []string{""}}, } for _, test := range commands { @@ -458,3 +462,25 @@ 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, ":serverlist", "help should list :serverlist") + assert.Contains(t, output, "go", "help should list go") +} diff --git a/pkg/sqlcmd/serverlist.go b/pkg/sqlcmd/serverlist.go new file mode 100644 index 00000000..efe29c7d --- /dev/null +++ b/pkg/sqlcmd/serverlist.go @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package sqlcmd + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "os" + "sort" + "strings" + "time" + + "github.com/microsoft/go-mssqldb/msdsn" +) + +// ListLocalServers queries the SQL Browser service for available SQL Server instances +// and writes the results to the provided writer. +func ListLocalServers(w io.Writer) { + instances, err := GetLocalServerInstances() + if err != nil { + fmt.Fprintln(os.Stderr, err) + } + for _, s := range instances { + fmt.Fprintf(w, " %s\n", s) + } +} + +// GetLocalServerInstances queries the SQL Browser service and returns a list of +// available SQL Server instances on the local machine. +// Returns an error for non-timeout network errors. +func GetLocalServerInstances() ([]string, error) { + bmsg := []byte{byte(msdsn.BrowserAllInstances)} + resp := make([]byte, 16*1024-1) + dialer := &net.Dialer{} + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + conn, err := dialer.DialContext(ctx, "udp", ":1434") + // silently ignore failures to connect, same as ODBC + if err != nil { + return nil, nil + } + defer conn.Close() + dl, _ := ctx.Deadline() + _ = conn.SetDeadline(dl) + _, err = conn.Write(bmsg) + if err != nil { + // Only return error if it's not a timeout + if !errors.Is(err, os.ErrDeadlineExceeded) { + return nil, err + } + return nil, nil + } + read, err := conn.Read(resp) + if err != nil { + // Only return error if it's not a timeout + if !errors.Is(err, os.ErrDeadlineExceeded) { + return nil, err + } + return nil, nil + } + + data := parseInstances(resp[:read]) + instances := make([]string, 0, len(data)) + + // Sort instance names for deterministic output + instanceNames := make([]string, 0, len(data)) + for s := range data { + instanceNames = append(instanceNames, s) + } + sort.Strings(instanceNames) + + for _, s := range instanceNames { + serverName := data[s]["ServerName"] + if serverName == "" { + // Skip instances without a ServerName + continue + } + if s == "MSSQLSERVER" { + instances = append(instances, "(local)", serverName) + } else { + instances = append(instances, fmt.Sprintf(`%s\%s`, serverName, s)) + } + } + return instances, nil +} + +func parseInstances(msg []byte) msdsn.BrowserData { + results := msdsn.BrowserData{} + if len(msg) > 3 && msg[0] == 5 { + outStr := string(msg[3:]) + tokens := strings.Split(outStr, ";") + instanceDict := map[string]string{} + gotName := false + var name string + for _, token := range tokens { + if gotName { + instanceDict[name] = token + gotName = false + } else { + name = token + if len(name) == 0 { + if len(instanceDict) == 0 { + break + } + // Only add if InstanceName key exists and is non-empty + if instName, ok := instanceDict["InstanceName"]; ok && instName != "" { + results[strings.ToUpper(instName)] = instanceDict + } + instanceDict = map[string]string{} + continue + } + gotName = true + } + } + } + return results +} diff --git a/pkg/sqlcmd/serverlist_test.go b/pkg/sqlcmd/serverlist_test.go new file mode 100644 index 00000000..3ab20920 --- /dev/null +++ b/pkg/sqlcmd/serverlist_test.go @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package sqlcmd + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestListLocalServers(t *testing.T) { + // Test that ListLocalServers writes to the provided writer without error + // Note: actual server discovery depends on SQL Browser service availability + var buf bytes.Buffer + ListLocalServers(&buf) + // We can't assert specific content since it depends on environment, + // but we verify it doesn't panic and writes valid output + t.Logf("ListLocalServers output: %q", buf.String()) +} + +func TestGetLocalServerInstances(t *testing.T) { + // Test that GetLocalServerInstances returns a slice (may be empty if no servers) + instances, err := GetLocalServerInstances() + // instances may be nil or empty if no SQL Browser is running, that's OK + // err may be non-nil for non-timeout network errors + if err != nil { + t.Logf("GetLocalServerInstances returned error (expected in some environments): %v", err) + } + t.Logf("Found %d instances", len(instances)) + for _, inst := range instances { + assert.NotEmpty(t, inst, "Instance name should not be empty") + } +} + +func TestParseInstances(t *testing.T) { + // Test parsing of SQL Browser response + // Format: 0x05 (response type), 2 bytes length, then semicolon-separated key=value pairs + // Each instance ends with two semicolons + + t.Run("empty response", func(t *testing.T) { + result := parseInstances([]byte{}) + assert.Empty(t, result) + }) + + t.Run("invalid header", func(t *testing.T) { + result := parseInstances([]byte{1, 0, 0}) + assert.Empty(t, result) + }) + + t.Run("valid single instance", func(t *testing.T) { + // Simulating SQL Browser response format + // Header: 0x05 followed by 2 length bytes, then the instance data + data := []byte{5, 0, 0} + instanceData := "ServerName;MYSERVER;InstanceName;MSSQLSERVER;IsClustered;No;Version;15.0.2000.5;tcp;1433;;" + data = append(data, []byte(instanceData)...) + + result := parseInstances(data) + assert.Len(t, result, 1) + assert.Contains(t, result, "MSSQLSERVER") + assert.Equal(t, "MYSERVER", result["MSSQLSERVER"]["ServerName"]) + assert.Equal(t, "1433", result["MSSQLSERVER"]["tcp"]) + }) + + t.Run("valid multiple instances", func(t *testing.T) { + data := []byte{5, 0, 0} + instanceData := "ServerName;MYSERVER;InstanceName;MSSQLSERVER;tcp;1433;;ServerName;MYSERVER;InstanceName;SQLEXPRESS;tcp;1434;;" + data = append(data, []byte(instanceData)...) + + result := parseInstances(data) + assert.Len(t, result, 2) + assert.Contains(t, result, "MSSQLSERVER") + assert.Contains(t, result, "SQLEXPRESS") + }) +} + +func TestServerlistCommand(t *testing.T) { + s, buf := setupSqlCmdWithMemoryOutput(t) + defer buf.Close() + + // Run the serverlist command + c := []string{":serverlist"} + err := runSqlCmd(t, s, c) + + // The command should not raise an error even if no servers are found + assert.NoError(t, err, ":serverlist should not raise error") + // Output may be empty if no SQL Browser is running + t.Logf("Serverlist output: %q", buf.buf.String()) +} From 8fd1d8fdd13f9e91d34237044c8d370b0b0a0c8e Mon Sep 17 00:00:00 2001 From: David Levy Date: Thu, 5 Feb 2026 12:58:19 -0600 Subject: [PATCH 02/17] feat: implement :serverlist and :help interactive commands - :serverlist queries SQL Browser service (UDP 1434) to discover instances - :help displays available sqlcmd commands - Refactored server listing logic to pkg/sqlcmd/serverlist.go for reuse --- pkg/sqlcmd/commands.go | 1425 +++++++++++++++++---------------- pkg/sqlcmd/commands_test.go | 2 +- pkg/sqlcmd/serverlist.go | 4 +- pkg/sqlcmd/serverlist_test.go | 2 +- 4 files changed, 717 insertions(+), 716 deletions(-) diff --git a/pkg/sqlcmd/commands.go b/pkg/sqlcmd/commands.go index 548632e6..72464494 100644 --- a/pkg/sqlcmd/commands.go +++ b/pkg/sqlcmd/commands.go @@ -1,712 +1,713 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -package sqlcmd - -import ( - "flag" - "fmt" - "os" - "regexp" - "sort" - "strconv" - "strings" - - "github.com/microsoft/go-sqlcmd/internal/color" - "golang.org/x/text/encoding/unicode" - "golang.org/x/text/transform" -) - -// Command defines a sqlcmd action which can be intermixed with the SQL batch -// Commands for sqlcmd are defined at https://docs.microsoft.com/sql/tools/sqlcmd-utility#sqlcmd-commands -type Command struct { - // regex must include at least one group if it has parameters - // Will be matched using FindStringSubmatch - regex *regexp.Regexp - // The function that implements the command. Third parameter is the line number - action func(*Sqlcmd, []string, uint) error - // Name of the command - name string - // whether the command is a system command - isSystem bool -} - -// Commands is the set of sqlcmd command implementations -type Commands map[string]*Command - -func newCommands() Commands { - // Commands is the set of Command implementations - return map[string]*Command{ - "EXIT": { - regex: regexp.MustCompile(`(?im)^[\t ]*?:?EXIT([\( \t]+.*\)*$|$)`), - action: exitCommand, - name: "EXIT", - }, - "QUIT": { - regex: regexp.MustCompile(`(?im)^[\t ]*?:?QUIT(?:[ \t]+(.*$)|$)`), - action: quitCommand, - name: "QUIT", - }, - "GO": { - regex: regexp.MustCompile(batchTerminatorRegex("GO")), - action: goCommand, - name: "GO", - }, - "OUT": { - regex: regexp.MustCompile(`(?im)^[ \t]*:OUT(?:[ \t]+(.*$)|$)`), - action: outCommand, - name: "OUT", - }, - "ERROR": { - regex: regexp.MustCompile(`(?im)^[ \t]*:ERROR(?:[ \t]+(.*$)|$)`), - action: errorCommand, - name: "ERROR", - }, "READFILE": { - regex: regexp.MustCompile(`(?im)^[ \t]*:R(?:[ \t]+(.*$)|$)`), - action: readFileCommand, - name: "READFILE", - }, - "SETVAR": { - regex: regexp.MustCompile(`(?im)^[ \t]*:SETVAR(?:[ \t]+(.*$)|$)`), - action: setVarCommand, - name: "SETVAR", - }, - "LISTVAR": { - regex: regexp.MustCompile(`(?im)^[\t ]*?:LISTVAR(?:[ \t]+(.*$)|$)`), - action: listVarCommand, - name: "LISTVAR", - }, - "RESET": { - regex: regexp.MustCompile(`(?im)^[ \t]*?:?RESET(?:[ \t]+(.*$)|$)`), - action: resetCommand, - name: "RESET", - }, - "LIST": { - regex: regexp.MustCompile(`(?im)^[ \t]*:LIST(?:[ \t]+(.*$)|$)`), - action: listCommand, - name: "LIST", - }, - "CONNECT": { - regex: regexp.MustCompile(`(?im)^[ \t]*:CONNECT(?:[ \t]+(.*$)|$)`), - action: connectCommand, - name: "CONNECT", - }, - "EXEC": { - regex: regexp.MustCompile(`(?im)^[ \t]*?:?!!(.*$)`), - action: execCommand, - name: "EXEC", - isSystem: true, - }, - "EDIT": { - regex: regexp.MustCompile(`(?im)^[\t ]*?:?ED(?:[ \t]+(.*$)|$)`), - action: editCommand, - name: "EDIT", - isSystem: true, - }, - "ONERROR": { - regex: regexp.MustCompile(`(?im)^[\t ]*?:?ON ERROR(?:[ \t]+(.*$)|$)`), - action: onerrorCommand, - name: "ONERROR", - }, - "XML": { - regex: regexp.MustCompile(`(?im)^[\t ]*?:XML(?:[ \t]+(.*$)|$)`), - action: xmlCommand, - name: "XML", - }, - "HELP": { - regex: regexp.MustCompile(`(?im)^[ \t]*:HELP(?:[ \t]+(.*$)|$)`), - action: helpCommand, - name: "HELP", - }, - "SERVERLIST": { - regex: regexp.MustCompile(`(?im)^[ \t]*:SERVERLIST(?:[ \t]+(.*$)|$)`), - action: serverlistCommand, - name: "SERVERLIST", - }, - } -} - -// DisableSysCommands disables the ED and :!! commands. -// When exitOnCall is true, running those commands will exit the process. -func (c Commands) DisableSysCommands(exitOnCall bool) { - f := warnDisabled - if exitOnCall { - f = errorDisabled - } - for _, cmd := range c { - if cmd.isSystem { - cmd.action = f - } - } -} - -func (c Commands) matchCommand(line string) (*Command, []string) { - for _, cmd := range c { - matchedCommand := cmd.regex.FindStringSubmatch(line) - if matchedCommand != nil { - return cmd, removeComments(matchedCommand[1:]) - } - } - return nil, nil -} - -func removeComments(args []string) []string { - var pos int - quote := false - for i := range args { - pos, quote = commentStart([]rune(args[i]), quote) - if pos > -1 { - out := make([]string, i+1) - if i > 0 { - copy(out, args[:i]) - } - out[i] = args[i][:pos] - return out - } - } - return args -} - -func commentStart(arg []rune, quote bool) (int, bool) { - var i int - space := true - for ; i < len(arg); i++ { - c, next := arg[i], grab(arg, i+1, len(arg)) - switch { - case quote && c == '"' && next != '"': - quote = false - case quote && c == '"' && next == '"': - i++ - case c == '\t' || c == ' ': - space = true - // Note we assume none of the regexes would split arguments on non-whitespace boundaries such that "text -- comment" would get split into "text -" and "- comment" - case !quote && space && c == '-' && next == '-': - return i, false - case !quote && c == '"': - quote = true - default: - space = false - } - } - return -1, quote -} - -func warnDisabled(s *Sqlcmd, args []string, line uint) error { - s.WriteError(s.GetError(), ErrCommandsDisabled) - return nil -} - -func errorDisabled(s *Sqlcmd, args []string, line uint) error { - s.WriteError(s.GetError(), ErrCommandsDisabled) - s.Exitcode = 1 - return ErrExitRequested -} - -func batchTerminatorRegex(terminator string) string { - return fmt.Sprintf(`(?im)^[\t ]*?%s(?:[ ]+(.*$)|$)`, regexp.QuoteMeta(terminator)) -} - -// SetBatchTerminator attempts to set the batch terminator to the given value -// Returns an error if the new value is not usable in the regex -func (c Commands) SetBatchTerminator(terminator string) error { - cmd := c["GO"] - regex, err := regexp.Compile(batchTerminatorRegex(terminator)) - if err != nil { - return err - } - cmd.regex = regex - return nil -} - -// exitCommand has 3 modes. -// With no (), it just exits without running any query -// With () it runs whatever batch is in the buffer then exits -// With any text between () it runs the text as a query then exits -func exitCommand(s *Sqlcmd, args []string, line uint) error { - if len(args) == 0 { - return ErrExitRequested - } - params := strings.TrimSpace(args[0]) - if params == "" { - return ErrExitRequested - } - if !strings.HasPrefix(params, "(") || !strings.HasSuffix(params, ")") { - return InvalidCommandError("EXIT", line) - } - // First we save the current batch - query1 := s.batch.String() - if len(query1) > 0 { - query1 = s.getRunnableQuery(query1) - } - // Now parse the params of EXIT as a batch without commands - cmd := s.batch.cmd - s.batch.cmd = nil - defer func() { - s.batch.cmd = cmd - }() - query2 := strings.TrimSpace(params[1 : len(params)-1]) - if len(query2) > 0 { - s.batch.Reset([]rune(query2)) - _, _, err := s.batch.Next() - if err != nil { - return err - } - query2 = s.batch.String() - if len(query2) > 0 { - query2 = s.getRunnableQuery(query2) - } - } - - if len(query1) > 0 || len(query2) > 0 { - query := query1 + SqlcmdEol + query2 - s.Exitcode, _ = s.runQuery(query) - } - return ErrExitRequested -} - -// quitCommand immediately exits the program without running any more batches -func quitCommand(s *Sqlcmd, args []string, line uint) error { - if args != nil && strings.TrimSpace(args[0]) != "" { - return InvalidCommandError("QUIT", line) - } - return ErrExitRequested -} - -// goCommand runs the current batch the number of times specified -func goCommand(s *Sqlcmd, args []string, line uint) error { - // default to 1 execution - n := 1 - var err error - if len(args) > 0 { - cnt := strings.TrimSpace(args[0]) - if cnt != "" { - if cnt, err = resolveArgumentVariables(s, []rune(cnt), true); err != nil { - return err - } - _, err = fmt.Sscanf(cnt, "%d", &n) - } - } - if err != nil || n < 1 { - return InvalidCommandError("GO", line) - } - if s.EchoInput { - err = listCommand(s, []string{}, line) - } - if err != nil { - return InvalidCommandError("GO", line) - } - query := s.batch.String() - if query == "" { - return nil - } - query = s.getRunnableQuery(query) - for i := 0; i < n; i++ { - if retcode, err := s.runQuery(query); err != nil { - s.Exitcode = retcode - return err - } - } - s.batch.Reset(nil) - return nil -} - -// outCommand changes the output writer to use a file -func outCommand(s *Sqlcmd, args []string, line uint) error { - if len(args) == 0 || args[0] == "" { - return InvalidCommandError("OUT", 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) - 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) - } - } - 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 - } - 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]) - } - s.SetError(o) - } - return nil -} - -func readFileCommand(s *Sqlcmd, args []string, line uint) error { - if args == nil || len(args) != 1 { - return InvalidCommandError(":R", line) - } - fileName, _ := resolveArgumentVariables(s, []rune(args[0]), false) - return s.IncludeFile(fileName, false) -} - -// setVarCommand parses a variable setting and applies it to the current Sqlcmd variables -func setVarCommand(s *Sqlcmd, args []string, line uint) error { - if args == nil || len(args) != 1 || args[0] == "" { - return InvalidCommandError(":SETVAR", line) - } - - varname := args[0] - val := "" - // The prior incarnation of sqlcmd doesn't require a space between the variable name and its value - // in some very unexpected cases. This version will require the space. - sp := strings.IndexRune(args[0], ' ') - if sp > -1 { - val = strings.TrimSpace(varname[sp:]) - varname = varname[:sp] - } - if err := s.vars.Setvar(varname, val); err != nil { - switch e := err.(type) { - case *VariableError: - return e - default: - return InvalidCommandError(":SETVAR", line) - } - } - return nil -} - -// listVarCommand prints the set of Sqlcmd scripting variables. -// Builtin values are printed first, followed by user-set values in sorted order. -func listVarCommand(s *Sqlcmd, args []string, line uint) error { - if args != nil && strings.TrimSpace(args[0]) != "" { - return InvalidCommandError("LISTVAR", line) - } - - vars := s.vars.All() - keys := make([]string, 0, len(vars)) - for k := range vars { - if !contains(builtinVariables, k) { - keys = append(keys, k) - } - } - sort.Strings(keys) - keys = append(builtinVariables, keys...) - for _, k := range keys { - fmt.Fprintf(s.GetOutput(), `%s = "%s"%s`, k, vars[k], SqlcmdEol) - } - return nil -} - -// resetCommand resets the statement cache -func resetCommand(s *Sqlcmd, args []string, line uint) error { - if s.batch != nil { - s.batch.Reset(nil) - } - - return nil -} - -// listCommand displays statements currently in the statement cache -func listCommand(s *Sqlcmd, args []string, line uint) (err error) { - cmd := "" - if args != nil { - if len(args) > 0 { - cmd = strings.ToLower(strings.TrimSpace(args[0])) - if len(args) > 1 || (cmd != "color" && cmd != "") { - return InvalidCommandError("LIST", line) - } - } - } - output := s.GetOutput() - if cmd == "color" { - sample := "select 'literal' as literal, 100 as number from [sys].[tables]" - clr := color.TextTypeTSql - if s.Format.IsXmlMode() { - sample = `value` - clr = color.TextTypeXml - } - // ignoring errors since it's not critical output - for _, style := range s.colorizer.Styles() { - _, _ = output.Write([]byte(style + ": ")) - _ = s.colorizer.Write(output, sample, style, clr) - _, _ = output.Write([]byte(SqlcmdEol)) - } - return - } - if s.batch == nil || s.batch.String() == "" { - return - } - - if err = s.colorizer.Write(output, s.batch.String(), s.vars.ColorScheme(), color.TextTypeTSql); err == nil { - _, err = output.Write([]byte(SqlcmdEol)) - } - - return -} - -func connectCommand(s *Sqlcmd, args []string, line uint) error { - if len(args) == 0 { - return InvalidCommandError("CONNECT", line) - } - - commandArgs := strings.Fields(args[0]) - - // Parse flags - flags := flag.NewFlagSet("connect", flag.ContinueOnError) - database := flags.String("D", "", "database name") - username := flags.String("U", "", "user name") - password := flags.String("P", "", "password") - loginTimeout := flags.String("l", "", "login timeout") - authenticationMethod := flags.String("G", "", "authentication method") - - err := flags.Parse(commandArgs[1:]) - //err := flags.Parse(args[1:]) - if err != nil { - return InvalidCommandError("CONNECT", line) - } - - connect := *s.Connect - connect.UserName, _ = resolveArgumentVariables(s, []rune(*username), false) - connect.Password, _ = resolveArgumentVariables(s, []rune(*password), false) - connect.Database, _ = resolveArgumentVariables(s, []rune(*database), false) - - timeout, _ := resolveArgumentVariables(s, []rune(*loginTimeout), false) - if timeout != "" { - if timeoutSeconds, err := strconv.ParseInt(timeout, 10, 32); err == nil { - if timeoutSeconds < 0 { - return InvalidCommandError("CONNECT", line) - } - connect.LoginTimeoutSeconds = int(timeoutSeconds) - } - } - - connect.AuthenticationMethod = *authenticationMethod - - // Set server name as the first positional argument - if len(commandArgs) > 0 { - connect.ServerName, _ = resolveArgumentVariables(s, []rune(commandArgs[0]), false) - } - - // If no user name is provided we switch to integrated auth - _ = s.ConnectDb(&connect, s.lineIo == nil) - - // ConnectDb prints connection errors already, and failure to connect is not fatal even with -b option - return nil -} - -func execCommand(s *Sqlcmd, args []string, line uint) error { - if len(args) == 0 { - return InvalidCommandError("EXEC", line) - } - cmdLine := strings.TrimSpace(args[0]) - if cmdLine == "" { - return InvalidCommandError("EXEC", line) - } - if cmdLine, err := resolveArgumentVariables(s, []rune(cmdLine), true); err != nil { - return err - } else { - cmd := sysCommand(cmdLine) - cmd.Stderr = s.GetError() - cmd.Stdout = s.GetOutput() - _ = cmd.Run() - } - return nil -} - -func editCommand(s *Sqlcmd, args []string, line uint) error { - if args != nil && strings.TrimSpace(args[0]) != "" { - return InvalidCommandError("ED", line) - } - file, err := os.CreateTemp("", "sq*.sql") - if err != nil { - return err - } - fileName := file.Name() - defer os.Remove(fileName) - text := s.batch.String() - if s.batch.State() == "-" { - text = fmt.Sprintf("%s%s", text, SqlcmdEol) - } - _, err = file.WriteString(text) - if err != nil { - return err - } - file.Close() - cmd := sysCommand(s.vars.TextEditor() + " " + `"` + fileName + `"`) - cmd.Stderr = s.GetError() - cmd.Stdout = s.GetOutput() - err = cmd.Run() - if err != nil { - return err - } - wasEcho := s.echoFileLines - s.echoFileLines = true - s.batch.Reset(nil) - _ = s.IncludeFile(fileName, false) - s.echoFileLines = wasEcho - return nil -} - -func onerrorCommand(s *Sqlcmd, args []string, line uint) error { - if len(args) == 0 || args[0] == "" { - return InvalidCommandError("ON ERROR", line) - } - params := strings.TrimSpace(args[0]) - - if strings.EqualFold(strings.ToLower(params), "exit") { - s.Connect.ExitOnError = true - } else if strings.EqualFold(strings.ToLower(params), "ignore") { - s.Connect.IgnoreError = true - s.Connect.ExitOnError = false - } else { - return InvalidCommandError("ON ERROR", line) - } - return nil -} - -func xmlCommand(s *Sqlcmd, args []string, line uint) error { - if len(args) != 1 || args[0] == "" { - return InvalidCommandError("XML", line) - } - params := strings.TrimSpace(args[0]) - // "OFF" and "ON" are documented as the allowed values. - // ODBC sqlcmd treats any value other than "ON" the same as "OFF". - // So we will too. - if strings.EqualFold(params, "on") { - s.Format.XmlMode(true) - } else { - s.Format.XmlMode(false) - } - return nil -} - -// helpCommand displays the list of available sqlcmd commands -func helpCommand(s *Sqlcmd, args []string, line uint) error { - if len(args) > 0 && strings.TrimSpace(args[0]) != "" { - return InvalidCommandError("HELP", line) - } - 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. -:quit - - Quits sqlcmd immediately. -:r - - Append file contents to the statement cache. -:reset - - Discards the statement cache. -:serverlist - - Lists local SQL Server instances. -: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 serverlistCommand(s *Sqlcmd, args []string, line uint) error { - if len(args) > 0 && strings.TrimSpace(args[0]) != "" { - return InvalidCommandError("SERVERLIST", line) - } - ListLocalServers(s.GetOutput()) - return nil -} - -func resolveArgumentVariables(s *Sqlcmd, arg []rune, failOnUnresolved bool) (string, error) { - var b *strings.Builder - end := len(arg) - for i := 0; i < end && !s.Connect.DisableVariableSubstitution; { - c, next := arg[i], grab(arg, i+1, end) - switch { - case c == '$' && next == '(': - vl, ok := readVariableReference(arg, i+2, end) - if ok { - varName := string(arg[i+2 : vl]) - val, ok := s.resolveVariable(varName) - if ok { - if b == nil { - b = new(strings.Builder) - b.Grow(len(arg)) - b.WriteString(string(arg[0:i])) - } - b.WriteString(val) - } else { - if failOnUnresolved { - return "", UndefinedVariable(varName) - } - s.WriteError(s.GetError(), UndefinedVariable(varName)) - if b != nil { - b.WriteString(string(arg[i : vl+1])) - } - } - i += ((vl - i) + 1) - } else { - if b != nil { - b.WriteString("$(") - } - i += 2 - } - default: - if b != nil { - b.WriteRune(c) - } - i++ - } - } - if b == nil { - return string(arg), nil - } - return b.String(), nil -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package sqlcmd + +import ( + "flag" + "fmt" + "os" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/microsoft/go-sqlcmd/internal/color" + "golang.org/x/text/encoding/unicode" + "golang.org/x/text/transform" +) + +// Command defines a sqlcmd action which can be intermixed with the SQL batch +// Commands for sqlcmd are defined at https://docs.microsoft.com/sql/tools/sqlcmd-utility#sqlcmd-commands +type Command struct { + // regex must include at least one group if it has parameters + // Will be matched using FindStringSubmatch + regex *regexp.Regexp + // The function that implements the command. Third parameter is the line number + action func(*Sqlcmd, []string, uint) error + // Name of the command + name string + // whether the command is a system command + isSystem bool +} + +// Commands is the set of sqlcmd command implementations +type Commands map[string]*Command + +func newCommands() Commands { + // Commands is the set of Command implementations + return map[string]*Command{ + "EXIT": { + regex: regexp.MustCompile(`(?im)^[\t ]*?:?EXIT([\( \t]+.*\)*$|$)`), + action: exitCommand, + name: "EXIT", + }, + "QUIT": { + regex: regexp.MustCompile(`(?im)^[\t ]*?:?QUIT(?:[ \t]+(.*$)|$)`), + action: quitCommand, + name: "QUIT", + }, + "GO": { + regex: regexp.MustCompile(batchTerminatorRegex("GO")), + action: goCommand, + name: "GO", + }, + "OUT": { + regex: regexp.MustCompile(`(?im)^[ \t]*:OUT(?:[ \t]+(.*$)|$)`), + action: outCommand, + name: "OUT", + }, + "ERROR": { + regex: regexp.MustCompile(`(?im)^[ \t]*:ERROR(?:[ \t]+(.*$)|$)`), + action: errorCommand, + name: "ERROR", + }, "READFILE": { + regex: regexp.MustCompile(`(?im)^[ \t]*:R(?:[ \t]+(.*$)|$)`), + action: readFileCommand, + name: "READFILE", + }, + "SETVAR": { + regex: regexp.MustCompile(`(?im)^[ \t]*:SETVAR(?:[ \t]+(.*$)|$)`), + action: setVarCommand, + name: "SETVAR", + }, + "LISTVAR": { + regex: regexp.MustCompile(`(?im)^[\t ]*?:LISTVAR(?:[ \t]+(.*$)|$)`), + action: listVarCommand, + name: "LISTVAR", + }, + "RESET": { + regex: regexp.MustCompile(`(?im)^[ \t]*?:?RESET(?:[ \t]+(.*$)|$)`), + action: resetCommand, + name: "RESET", + }, + "LIST": { + regex: regexp.MustCompile(`(?im)^[ \t]*:LIST(?:[ \t]+(.*$)|$)`), + action: listCommand, + name: "LIST", + }, + "CONNECT": { + regex: regexp.MustCompile(`(?im)^[ \t]*:CONNECT(?:[ \t]+(.*$)|$)`), + action: connectCommand, + name: "CONNECT", + }, + "EXEC": { + regex: regexp.MustCompile(`(?im)^[ \t]*?:?!!(.*$)`), + action: execCommand, + name: "EXEC", + isSystem: true, + }, + "EDIT": { + regex: regexp.MustCompile(`(?im)^[\t ]*?:?ED(?:[ \t]+(.*$)|$)`), + action: editCommand, + name: "EDIT", + isSystem: true, + }, + "ONERROR": { + regex: regexp.MustCompile(`(?im)^[\t ]*?:?ON ERROR(?:[ \t]+(.*$)|$)`), + action: onerrorCommand, + name: "ONERROR", + }, + "XML": { + regex: regexp.MustCompile(`(?im)^[\t ]*?:XML(?:[ \t]+(.*$)|$)`), + action: xmlCommand, + name: "XML", + }, + "HELP": { + regex: regexp.MustCompile(`(?im)^[ \t]*:HELP(?:[ \t]+(.*$)|$)`), + action: helpCommand, + name: "HELP", + }, + "SERVERLIST": { + regex: regexp.MustCompile(`(?im)^[ \t]*:SERVERLIST(?:[ \t]+(.*$)|$)`), + action: serverlistCommand, + name: "SERVERLIST", + }, + } +} + +// DisableSysCommands disables the ED and :!! commands. +// When exitOnCall is true, running those commands will exit the process. +func (c Commands) DisableSysCommands(exitOnCall bool) { + f := warnDisabled + if exitOnCall { + f = errorDisabled + } + for _, cmd := range c { + if cmd.isSystem { + cmd.action = f + } + } +} + +func (c Commands) matchCommand(line string) (*Command, []string) { + for _, cmd := range c { + matchedCommand := cmd.regex.FindStringSubmatch(line) + if matchedCommand != nil { + return cmd, removeComments(matchedCommand[1:]) + } + } + return nil, nil +} + +func removeComments(args []string) []string { + var pos int + quote := false + for i := range args { + pos, quote = commentStart([]rune(args[i]), quote) + if pos > -1 { + out := make([]string, i+1) + if i > 0 { + copy(out, args[:i]) + } + out[i] = args[i][:pos] + return out + } + } + return args +} + +func commentStart(arg []rune, quote bool) (int, bool) { + var i int + space := true + for ; i < len(arg); i++ { + c, next := arg[i], grab(arg, i+1, len(arg)) + switch { + case quote && c == '"' && next != '"': + quote = false + case quote && c == '"' && next == '"': + i++ + case c == '\t' || c == ' ': + space = true + // Note we assume none of the regexes would split arguments on non-whitespace boundaries such that "text -- comment" would get split into "text -" and "- comment" + case !quote && space && c == '-' && next == '-': + return i, false + case !quote && c == '"': + quote = true + default: + space = false + } + } + return -1, quote +} + +func warnDisabled(s *Sqlcmd, args []string, line uint) error { + s.WriteError(s.GetError(), ErrCommandsDisabled) + return nil +} + +func errorDisabled(s *Sqlcmd, args []string, line uint) error { + s.WriteError(s.GetError(), ErrCommandsDisabled) + s.Exitcode = 1 + return ErrExitRequested +} + +func batchTerminatorRegex(terminator string) string { + return fmt.Sprintf(`(?im)^[\t ]*?%s(?:[ ]+(.*$)|$)`, regexp.QuoteMeta(terminator)) +} + +// SetBatchTerminator attempts to set the batch terminator to the given value +// Returns an error if the new value is not usable in the regex +func (c Commands) SetBatchTerminator(terminator string) error { + cmd := c["GO"] + regex, err := regexp.Compile(batchTerminatorRegex(terminator)) + if err != nil { + return err + } + cmd.regex = regex + return nil +} + +// exitCommand has 3 modes. +// With no (), it just exits without running any query +// With () it runs whatever batch is in the buffer then exits +// With any text between () it runs the text as a query then exits +func exitCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) == 0 { + return ErrExitRequested + } + params := strings.TrimSpace(args[0]) + if params == "" { + return ErrExitRequested + } + if !strings.HasPrefix(params, "(") || !strings.HasSuffix(params, ")") { + return InvalidCommandError("EXIT", line) + } + // First we save the current batch + query1 := s.batch.String() + if len(query1) > 0 { + query1 = s.getRunnableQuery(query1) + } + // Now parse the params of EXIT as a batch without commands + cmd := s.batch.cmd + s.batch.cmd = nil + defer func() { + s.batch.cmd = cmd + }() + query2 := strings.TrimSpace(params[1 : len(params)-1]) + if len(query2) > 0 { + s.batch.Reset([]rune(query2)) + _, _, err := s.batch.Next() + if err != nil { + return err + } + query2 = s.batch.String() + if len(query2) > 0 { + query2 = s.getRunnableQuery(query2) + } + } + + if len(query1) > 0 || len(query2) > 0 { + query := query1 + SqlcmdEol + query2 + s.Exitcode, _ = s.runQuery(query) + } + return ErrExitRequested +} + +// quitCommand immediately exits the program without running any more batches +func quitCommand(s *Sqlcmd, args []string, line uint) error { + if args != nil && strings.TrimSpace(args[0]) != "" { + return InvalidCommandError("QUIT", line) + } + return ErrExitRequested +} + +// goCommand runs the current batch the number of times specified +func goCommand(s *Sqlcmd, args []string, line uint) error { + // default to 1 execution + n := 1 + var err error + if len(args) > 0 { + cnt := strings.TrimSpace(args[0]) + if cnt != "" { + if cnt, err = resolveArgumentVariables(s, []rune(cnt), true); err != nil { + return err + } + _, err = fmt.Sscanf(cnt, "%d", &n) + } + } + if err != nil || n < 1 { + return InvalidCommandError("GO", line) + } + if s.EchoInput { + err = listCommand(s, []string{}, line) + } + if err != nil { + return InvalidCommandError("GO", line) + } + query := s.batch.String() + if query == "" { + return nil + } + query = s.getRunnableQuery(query) + for i := 0; i < n; i++ { + if retcode, err := s.runQuery(query); err != nil { + s.Exitcode = retcode + return err + } + } + s.batch.Reset(nil) + return nil +} + +// outCommand changes the output writer to use a file +func outCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) == 0 || args[0] == "" { + return InvalidCommandError("OUT", 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) + 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) + } + } + 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 + } + 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]) + } + s.SetError(o) + } + return nil +} + +func readFileCommand(s *Sqlcmd, args []string, line uint) error { + if args == nil || len(args) != 1 { + return InvalidCommandError(":R", line) + } + fileName, _ := resolveArgumentVariables(s, []rune(args[0]), false) + return s.IncludeFile(fileName, false) +} + +// setVarCommand parses a variable setting and applies it to the current Sqlcmd variables +func setVarCommand(s *Sqlcmd, args []string, line uint) error { + if args == nil || len(args) != 1 || args[0] == "" { + return InvalidCommandError(":SETVAR", line) + } + + varname := args[0] + val := "" + // The prior incarnation of sqlcmd doesn't require a space between the variable name and its value + // in some very unexpected cases. This version will require the space. + sp := strings.IndexRune(args[0], ' ') + if sp > -1 { + val = strings.TrimSpace(varname[sp:]) + varname = varname[:sp] + } + if err := s.vars.Setvar(varname, val); err != nil { + switch e := err.(type) { + case *VariableError: + return e + default: + return InvalidCommandError(":SETVAR", line) + } + } + return nil +} + +// listVarCommand prints the set of Sqlcmd scripting variables. +// Builtin values are printed first, followed by user-set values in sorted order. +func listVarCommand(s *Sqlcmd, args []string, line uint) error { + if args != nil && strings.TrimSpace(args[0]) != "" { + return InvalidCommandError("LISTVAR", line) + } + + vars := s.vars.All() + keys := make([]string, 0, len(vars)) + for k := range vars { + if !contains(builtinVariables, k) { + keys = append(keys, k) + } + } + sort.Strings(keys) + keys = append(builtinVariables, keys...) + for _, k := range keys { + fmt.Fprintf(s.GetOutput(), `%s = "%s"%s`, k, vars[k], SqlcmdEol) + } + return nil +} + +// resetCommand resets the statement cache +func resetCommand(s *Sqlcmd, args []string, line uint) error { + if s.batch != nil { + s.batch.Reset(nil) + } + + return nil +} + +// listCommand displays statements currently in the statement cache +func listCommand(s *Sqlcmd, args []string, line uint) (err error) { + cmd := "" + if args != nil { + if len(args) > 0 { + cmd = strings.ToLower(strings.TrimSpace(args[0])) + if len(args) > 1 || (cmd != "color" && cmd != "") { + return InvalidCommandError("LIST", line) + } + } + } + output := s.GetOutput() + if cmd == "color" { + sample := "select 'literal' as literal, 100 as number from [sys].[tables]" + clr := color.TextTypeTSql + if s.Format.IsXmlMode() { + sample = `value` + clr = color.TextTypeXml + } + // ignoring errors since it's not critical output + for _, style := range s.colorizer.Styles() { + _, _ = output.Write([]byte(style + ": ")) + _ = s.colorizer.Write(output, sample, style, clr) + _, _ = output.Write([]byte(SqlcmdEol)) + } + return + } + if s.batch == nil || s.batch.String() == "" { + return + } + + if err = s.colorizer.Write(output, s.batch.String(), s.vars.ColorScheme(), color.TextTypeTSql); err == nil { + _, err = output.Write([]byte(SqlcmdEol)) + } + + return +} + +func connectCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) == 0 { + return InvalidCommandError("CONNECT", line) + } + + commandArgs := strings.Fields(args[0]) + + // Parse flags + flags := flag.NewFlagSet("connect", flag.ContinueOnError) + database := flags.String("D", "", "database name") + username := flags.String("U", "", "user name") + password := flags.String("P", "", "password") + loginTimeout := flags.String("l", "", "login timeout") + authenticationMethod := flags.String("G", "", "authentication method") + + err := flags.Parse(commandArgs[1:]) + //err := flags.Parse(args[1:]) + if err != nil { + return InvalidCommandError("CONNECT", line) + } + + connect := *s.Connect + connect.UserName, _ = resolveArgumentVariables(s, []rune(*username), false) + connect.Password, _ = resolveArgumentVariables(s, []rune(*password), false) + connect.Database, _ = resolveArgumentVariables(s, []rune(*database), false) + + timeout, _ := resolveArgumentVariables(s, []rune(*loginTimeout), false) + if timeout != "" { + if timeoutSeconds, err := strconv.ParseInt(timeout, 10, 32); err == nil { + if timeoutSeconds < 0 { + return InvalidCommandError("CONNECT", line) + } + connect.LoginTimeoutSeconds = int(timeoutSeconds) + } + } + + connect.AuthenticationMethod = *authenticationMethod + + // Set server name as the first positional argument + if len(commandArgs) > 0 { + connect.ServerName, _ = resolveArgumentVariables(s, []rune(commandArgs[0]), false) + } + + // If no user name is provided we switch to integrated auth + _ = s.ConnectDb(&connect, s.lineIo == nil) + + // ConnectDb prints connection errors already, and failure to connect is not fatal even with -b option + return nil +} + +func execCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) == 0 { + return InvalidCommandError("EXEC", line) + } + cmdLine := strings.TrimSpace(args[0]) + if cmdLine == "" { + return InvalidCommandError("EXEC", line) + } + if cmdLine, err := resolveArgumentVariables(s, []rune(cmdLine), true); err != nil { + return err + } else { + cmd := sysCommand(cmdLine) + cmd.Stderr = s.GetError() + cmd.Stdout = s.GetOutput() + _ = cmd.Run() + } + return nil +} + +func editCommand(s *Sqlcmd, args []string, line uint) error { + if args != nil && strings.TrimSpace(args[0]) != "" { + return InvalidCommandError("ED", line) + } + file, err := os.CreateTemp("", "sq*.sql") + if err != nil { + return err + } + fileName := file.Name() + defer os.Remove(fileName) + text := s.batch.String() + if s.batch.State() == "-" { + text = fmt.Sprintf("%s%s", text, SqlcmdEol) + } + _, err = file.WriteString(text) + if err != nil { + return err + } + file.Close() + cmd := sysCommand(s.vars.TextEditor() + " " + `"` + fileName + `"`) + cmd.Stderr = s.GetError() + cmd.Stdout = s.GetOutput() + err = cmd.Run() + if err != nil { + return err + } + wasEcho := s.echoFileLines + s.echoFileLines = true + s.batch.Reset(nil) + _ = s.IncludeFile(fileName, false) + s.echoFileLines = wasEcho + return nil +} + +func onerrorCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) == 0 || args[0] == "" { + return InvalidCommandError("ON ERROR", line) + } + params := strings.TrimSpace(args[0]) + + if strings.EqualFold(strings.ToLower(params), "exit") { + s.Connect.ExitOnError = true + } else if strings.EqualFold(strings.ToLower(params), "ignore") { + s.Connect.IgnoreError = true + s.Connect.ExitOnError = false + } else { + return InvalidCommandError("ON ERROR", line) + } + return nil +} + +func xmlCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) != 1 || args[0] == "" { + return InvalidCommandError("XML", line) + } + params := strings.TrimSpace(args[0]) + // "OFF" and "ON" are documented as the allowed values. + // ODBC sqlcmd treats any value other than "ON" the same as "OFF". + // So we will too. + if strings.EqualFold(params, "on") { + s.Format.XmlMode(true) + } else { + s.Format.XmlMode(false) + } + return nil +} + +// helpCommand displays the list of available sqlcmd commands +func helpCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) > 0 && strings.TrimSpace(args[0]) != "" { + return InvalidCommandError("HELP", line) + } + 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. +:quit + - Quits sqlcmd immediately. +:r + - Append file contents to the statement cache. +:reset + - Discards the statement cache. +:serverlist + - Lists local SQL Server instances. +: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 +} + +// serverlistCommand lists locally available SQL Server instances +func serverlistCommand(s *Sqlcmd, args []string, line uint) error { + if len(args) > 0 && strings.TrimSpace(args[0]) != "" { + return InvalidCommandError("SERVERLIST", line) + } + ListLocalServers(s.GetOutput()) + return nil +} + +func resolveArgumentVariables(s *Sqlcmd, arg []rune, failOnUnresolved bool) (string, error) { + var b *strings.Builder + end := len(arg) + for i := 0; i < end && !s.Connect.DisableVariableSubstitution; { + c, next := arg[i], grab(arg, i+1, end) + switch { + case c == '$' && next == '(': + vl, ok := readVariableReference(arg, i+2, end) + if ok { + varName := string(arg[i+2 : vl]) + val, ok := s.resolveVariable(varName) + if ok { + if b == nil { + b = new(strings.Builder) + b.Grow(len(arg)) + b.WriteString(string(arg[0:i])) + } + b.WriteString(val) + } else { + if failOnUnresolved { + return "", UndefinedVariable(varName) + } + s.WriteError(s.GetError(), UndefinedVariable(varName)) + if b != nil { + b.WriteString(string(arg[i : vl+1])) + } + } + i += ((vl - i) + 1) + } else { + if b != nil { + b.WriteString("$(") + } + i += 2 + } + default: + if b != nil { + b.WriteRune(c) + } + i++ + } + } + if b == nil { + return string(arg), nil + } + return b.String(), nil +} diff --git a/pkg/sqlcmd/commands_test.go b/pkg/sqlcmd/commands_test.go index 7895e307..a84c640a 100644 --- a/pkg/sqlcmd/commands_test.go +++ b/pkg/sqlcmd/commands_test.go @@ -465,7 +465,7 @@ func TestExitCommandAppendsParameterToCurrentBatch(t *testing.T) { func TestHelpCommand(t *testing.T) { s, buf := setupSqlCmdWithMemoryOutput(t) - defer buf.Close() + defer func() { _ = buf.Close() }() s.SetOutput(buf) err := helpCommand(s, []string{""}, 1) diff --git a/pkg/sqlcmd/serverlist.go b/pkg/sqlcmd/serverlist.go index efe29c7d..021933d2 100644 --- a/pkg/sqlcmd/serverlist.go +++ b/pkg/sqlcmd/serverlist.go @@ -25,7 +25,7 @@ func ListLocalServers(w io.Writer) { fmt.Fprintln(os.Stderr, err) } for _, s := range instances { - fmt.Fprintf(w, " %s\n", s) + _, _ = fmt.Fprintf(w, " %s\n", s) } } @@ -43,7 +43,7 @@ func GetLocalServerInstances() ([]string, error) { if err != nil { return nil, nil } - defer conn.Close() + defer func() { _ = conn.Close() }() dl, _ := ctx.Deadline() _ = conn.SetDeadline(dl) _, err = conn.Write(bmsg) diff --git a/pkg/sqlcmd/serverlist_test.go b/pkg/sqlcmd/serverlist_test.go index 3ab20920..6c50ee72 100644 --- a/pkg/sqlcmd/serverlist_test.go +++ b/pkg/sqlcmd/serverlist_test.go @@ -77,7 +77,7 @@ func TestParseInstances(t *testing.T) { func TestServerlistCommand(t *testing.T) { s, buf := setupSqlCmdWithMemoryOutput(t) - defer buf.Close() + defer func() { _ = buf.Close() }() // Run the serverlist command c := []string{":serverlist"} From 5be11cb04acec259283371b325fe63069fbeac29 Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 17 Apr 2026 15:58:53 -0500 Subject: [PATCH 03/17] fix: MSSQLSERVER dual-entry bug, README batch script, and test DB dependency - Remove duplicate (local) entry for default MSSQLSERVER instance - Fix README batch script: %%~z outside FOR loop doesn't work, use FOR %%I - TestHelpCommand/TestServerlistCommand no longer require DB connection --- README.md | 2 +- pkg/sqlcmd/commands_test.go | 6 ++++-- pkg/sqlcmd/serverlist.go | 2 +- pkg/sqlcmd/serverlist_test.go | 10 ++++++---- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3f773351..1e9adfa4 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ echo No SQL Server instances found To capture stderr separately (for error logging): ```batch sqlcmd -Q ":serverlist" 2>errors.log > servers.txt -if exist errors.log if not "%%~z errors.log"=="0" type errors.log +if exist errors.log for %%I in (errors.log) do if %%~zI gtr 0 type errors.log ``` ``` diff --git a/pkg/sqlcmd/commands_test.go b/pkg/sqlcmd/commands_test.go index 4abf0706..af515abf 100644 --- a/pkg/sqlcmd/commands_test.go +++ b/pkg/sqlcmd/commands_test.go @@ -470,9 +470,11 @@ func TestExitCommandAppendsParameterToCurrentBatch(t *testing.T) { } func TestHelpCommand(t *testing.T) { - s, buf := setupSqlCmdWithMemoryOutput(t) - defer func() { _ = buf.Close() }() + v := InitializeVariables(false) + s := New(nil, "", v) + 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") diff --git a/pkg/sqlcmd/serverlist.go b/pkg/sqlcmd/serverlist.go index 021933d2..8e21f25f 100644 --- a/pkg/sqlcmd/serverlist.go +++ b/pkg/sqlcmd/serverlist.go @@ -80,7 +80,7 @@ func GetLocalServerInstances() ([]string, error) { continue } if s == "MSSQLSERVER" { - instances = append(instances, "(local)", serverName) + instances = append(instances, serverName) } else { instances = append(instances, fmt.Sprintf(`%s\%s`, serverName, s)) } diff --git a/pkg/sqlcmd/serverlist_test.go b/pkg/sqlcmd/serverlist_test.go index 6c50ee72..a1d2fc8c 100644 --- a/pkg/sqlcmd/serverlist_test.go +++ b/pkg/sqlcmd/serverlist_test.go @@ -76,12 +76,14 @@ func TestParseInstances(t *testing.T) { } func TestServerlistCommand(t *testing.T) { - s, buf := setupSqlCmdWithMemoryOutput(t) + v := InitializeVariables(false) + s := New(nil, "", v) + buf := &memoryBuffer{buf: new(bytes.Buffer)} + s.SetOutput(buf) defer func() { _ = buf.Close() }() - // Run the serverlist command - c := []string{":serverlist"} - err := runSqlCmd(t, s, c) + // Run the serverlist command directly - no DB connection needed + err := serverlistCommand(s, []string{""}, 1) // The command should not raise an error even if no servers are found assert.NoError(t, err, ":serverlist should not raise error") From e915b54b28acd933c1479a1d5ad79b843d964dd9 Mon Sep 17 00:00:00 2001 From: David Levy Date: Mon, 20 Apr 2026 16:15:31 -0500 Subject: [PATCH 04/17] fix: ListLocalServers returns error instead of writing to os.Stderr --- cmd/sqlcmd/sqlcmd.go | 4 +++- pkg/sqlcmd/commands.go | 4 +++- pkg/sqlcmd/serverlist.go | 5 +++-- pkg/sqlcmd/serverlist_test.go | 5 ++++- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/cmd/sqlcmd/sqlcmd.go b/cmd/sqlcmd/sqlcmd.go index cf142d94..ecb50b6b 100644 --- a/cmd/sqlcmd/sqlcmd.go +++ b/cmd/sqlcmd/sqlcmd.go @@ -232,7 +232,9 @@ func Execute(version string) { fmt.Println() fmt.Println(localizer.Sprintf("Servers:")) } - sqlcmd.ListLocalServers(os.Stdout) + if err := sqlcmd.ListLocalServers(os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + } os.Exit(0) } if len(argss) > 0 { diff --git a/pkg/sqlcmd/commands.go b/pkg/sqlcmd/commands.go index 72464494..5865bc41 100644 --- a/pkg/sqlcmd/commands.go +++ b/pkg/sqlcmd/commands.go @@ -661,7 +661,9 @@ func serverlistCommand(s *Sqlcmd, args []string, line uint) error { if len(args) > 0 && strings.TrimSpace(args[0]) != "" { return InvalidCommandError("SERVERLIST", line) } - ListLocalServers(s.GetOutput()) + if err := ListLocalServers(s.GetOutput()); err != nil { + _, _ = fmt.Fprintln(s.GetError(), err) + } return nil } diff --git a/pkg/sqlcmd/serverlist.go b/pkg/sqlcmd/serverlist.go index 8e21f25f..17bab7f0 100644 --- a/pkg/sqlcmd/serverlist.go +++ b/pkg/sqlcmd/serverlist.go @@ -19,14 +19,15 @@ import ( // ListLocalServers queries the SQL Browser service for available SQL Server instances // and writes the results to the provided writer. -func ListLocalServers(w io.Writer) { +func ListLocalServers(w io.Writer) error { instances, err := GetLocalServerInstances() if err != nil { - fmt.Fprintln(os.Stderr, err) + return err } for _, s := range instances { _, _ = fmt.Fprintf(w, " %s\n", s) } + return nil } // GetLocalServerInstances queries the SQL Browser service and returns a list of diff --git a/pkg/sqlcmd/serverlist_test.go b/pkg/sqlcmd/serverlist_test.go index a1d2fc8c..4551d1f3 100644 --- a/pkg/sqlcmd/serverlist_test.go +++ b/pkg/sqlcmd/serverlist_test.go @@ -14,7 +14,10 @@ func TestListLocalServers(t *testing.T) { // Test that ListLocalServers writes to the provided writer without error // Note: actual server discovery depends on SQL Browser service availability var buf bytes.Buffer - ListLocalServers(&buf) + err := ListLocalServers(&buf) + if err != nil { + t.Logf("ListLocalServers returned error (expected in some environments): %v", err) + } // We can't assert specific content since it depends on environment, // but we verify it doesn't panic and writes valid output t.Logf("ListLocalServers output: %q", buf.String()) From b5053d1f6362f7d08120d683e6a14e73b792c942 Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 24 Apr 2026 15:52:47 -0500 Subject: [PATCH 05/17] docs: document parseInstances behavior change for missing InstanceName --- pkg/sqlcmd/serverlist.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/sqlcmd/serverlist.go b/pkg/sqlcmd/serverlist.go index 17bab7f0..dcf90a8b 100644 --- a/pkg/sqlcmd/serverlist.go +++ b/pkg/sqlcmd/serverlist.go @@ -107,7 +107,9 @@ func parseInstances(msg []byte) msdsn.BrowserData { if len(instanceDict) == 0 { break } - // Only add if InstanceName key exists and is non-empty + // Deliberate behavior change from go-mssqldb's parseInstances: + // skip entries with missing or empty InstanceName (e.g. malformed + // registry data) instead of adding them under an empty key. if instName, ok := instanceDict["InstanceName"]; ok && instName != "" { results[strings.ToUpper(instName)] = instanceDict } From 3b6377523dca625b70bad506f82ffa8415288827 Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 4 Sep 2026 16:18:41 -0500 Subject: [PATCH 06/17] test: make server list tests deterministic Treat connection-refused responses as an unavailable SQL Browser service and avoid live UDP calls in unit tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/sqlcmd/serverlist.go | 27 ++++++++++-------- pkg/sqlcmd/serverlist_test.go | 53 +++++++++++++++++------------------ 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/pkg/sqlcmd/serverlist.go b/pkg/sqlcmd/serverlist.go index a7ad59d4..67eeb1e6 100644 --- a/pkg/sqlcmd/serverlist.go +++ b/pkg/sqlcmd/serverlist.go @@ -12,15 +12,18 @@ import ( "os" "sort" "strings" + "syscall" "time" "github.com/microsoft/go-mssqldb/msdsn" ) +var getLocalServerInstances = GetLocalServerInstances + // ListLocalServers queries the SQL Browser service for available SQL Server instances // and writes the results to the provided writer. func ListLocalServers(w io.Writer) error { - instances, err := GetLocalServerInstances() + instances, err := getLocalServerInstances() if err != nil { return err } @@ -31,8 +34,8 @@ func ListLocalServers(w io.Writer) error { } // GetLocalServerInstances queries the SQL Browser service and returns a list of -// available SQL Server instances on the local machine. -// Returns an error for non-timeout network errors. +// available SQL Server instances on the local machine. An unavailable Browser +// service returns no instances; other post-connect network failures are returned. func GetLocalServerInstances() ([]string, error) { bmsg := []byte{byte(msdsn.BrowserAllInstances)} resp := make([]byte, 16*1024-1) @@ -49,25 +52,27 @@ func GetLocalServerInstances() ([]string, error) { _ = conn.SetDeadline(dl) _, err = conn.Write(bmsg) if err != nil { - // Only return error if it's not a timeout - if !errors.Is(err, os.ErrDeadlineExceeded) { - return nil, err + if isBrowserUnavailableError(err) { + return nil, nil } - return nil, nil + return nil, err } read, err := conn.Read(resp) if err != nil { - // Only return error if it's not a timeout - if !errors.Is(err, os.ErrDeadlineExceeded) { - return nil, err + if isBrowserUnavailableError(err) { + return nil, nil } - return nil, nil + return nil, err } data := parseInstances(resp[:read]) return localServerInstanceNames(data), nil } +func isBrowserUnavailableError(err error) bool { + return errors.Is(err, os.ErrDeadlineExceeded) || errors.Is(err, syscall.ECONNREFUSED) +} + func localServerInstanceNames(data msdsn.BrowserData) []string { instances := make([]string, 0, len(data)) diff --git a/pkg/sqlcmd/serverlist_test.go b/pkg/sqlcmd/serverlist_test.go index 27d6f9c9..be913c68 100644 --- a/pkg/sqlcmd/serverlist_test.go +++ b/pkg/sqlcmd/serverlist_test.go @@ -5,6 +5,9 @@ package sqlcmd import ( "bytes" + "errors" + "fmt" + "syscall" "testing" "github.com/microsoft/go-mssqldb/msdsn" @@ -12,35 +15,21 @@ import ( ) func TestListLocalServers(t *testing.T) { - // Test that ListLocalServers writes to the provided writer without error - // Note: actual server discovery depends on SQL Browser service availability - var buf bytes.Buffer - err := ListLocalServers(&buf) - if err != nil { - t.Logf("ListLocalServers returned error (expected in some environments): %v", err) + original := getLocalServerInstances + getLocalServerInstances = func() ([]string, error) { + return []string{`MYSERVER\SQL2019`, `MYSERVER\SQL2022`}, nil } - // We can't assert specific content since it depends on environment, - // but we verify it doesn't panic and writes valid output - t.Logf("ListLocalServers output: %q", buf.String()) -} + defer func() { getLocalServerInstances = original }() -func TestGetLocalServerInstances(t *testing.T) { - // Test that GetLocalServerInstances returns a slice (may be empty if no servers) - instances, err := GetLocalServerInstances() - // instances may be nil or empty if no SQL Browser is running, that's OK - // err may be non-nil for non-timeout network errors - if err != nil { - t.Logf("GetLocalServerInstances returned error (expected in some environments): %v", err) - } - t.Logf("Found %d instances", len(instances)) - for _, inst := range instances { - assert.NotEmpty(t, inst, "Instance name should not be empty") - } + var buf bytes.Buffer + + assert.NoError(t, ListLocalServers(&buf)) + assert.Equal(t, " MYSERVER\\SQL2019\n MYSERVER\\SQL2022\n", buf.String()) } func TestParseInstances(t *testing.T) { // Test parsing of SQL Browser response - // Format: 0x05 (response type), 2 bytes length, then semicolon-separated key=value pairs + // Format: 0x05 (response type), 2 bytes length, then alternating key;value tokens // Each instance ends with two semicolons t.Run("empty response", func(t *testing.T) { @@ -89,18 +78,26 @@ func TestLocalServerInstanceNamesSkipsMissingServerNames(t *testing.T) { assert.Equal(t, []string{`MYSERVER\VALID`}, localServerInstanceNames(data)) } +func TestIsBrowserUnavailableError(t *testing.T) { + assert.True(t, isBrowserUnavailableError(fmt.Errorf("browser unavailable: %w", syscall.ECONNREFUSED))) + assert.False(t, isBrowserUnavailableError(errors.New("network failure"))) +} + func TestServerlistCommand(t *testing.T) { + original := getLocalServerInstances + getLocalServerInstances = func() ([]string, error) { + return []string{`MYSERVER\SQL2019`}, nil + } + defer func() { getLocalServerInstances = original }() + v := InitializeVariables(false) s := New(nil, "", v) buf := &memoryBuffer{buf: new(bytes.Buffer)} s.SetOutput(buf) defer func() { _ = buf.Close() }() - // Run the serverlist command directly - no DB connection needed err := serverlistCommand(s, []string{""}, 1) - // The command should not raise an error even if no servers are found - assert.NoError(t, err, ":serverlist should not raise error") - // Output may be empty if no SQL Browser is running - t.Logf("Serverlist output: %q", buf.buf.String()) + assert.NoError(t, err) + assert.Equal(t, " MYSERVER\\SQL2019\n", buf.buf.String()) } From 473d86ef96800b9b89973451f5859f8a26f00ea8 Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 4 Sep 2026 16:27:41 -0500 Subject: [PATCH 07/17] docs: correct server list error handling Document stderr-based batch checks and tighten the help command assertion. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 16 +++++----------- pkg/sqlcmd/commands_test.go | 2 +- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2b8fdc6f..a7f6d6c3 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ switches are most important to you to have implemented next in the new sqlcmd. - 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. -- `:serverlist` lists local SQL Server instances discovered via the SQL Server Browser service (UDP port 1434). The command queries the SQL Browser service and displays the server name and instance name for each discovered instance. If no instances are found or the Browser service is not running, no output is produced. Non-timeout errors are printed to stderr. +- `:serverlist` lists local SQL Server instances discovered via the SQL Server Browser service (UDP port 1434). The command queries the SQL Browser service and displays the server name and instance name for each discovered instance. If no instances are found or the Browser service cannot be reached, no output is produced. Other post-connect errors are printed to stderr. - Sqlcmd defaults to a horizontal output format (space separated, no borders). To use the new ASCII table format, use the new `--ascii` command line option or set `SQLCMDFORMAT` to `ascii` (`-v SQLCMDFORMAT=ascii`). Note that when using the ASCII table format, individual column widths are determined by the content, but the `SQLCMDCOLWIDTH` variable and the `-w` parameter are still used to control the maximum screen width, determining when columns wrap into separate table segments. The following variables are ignored: `SQLCMDMAXFIXEDTYPEWIDTH`, `SQLCMDMAXVARTYPEWIDTH`, and `SQLCMDHEADERS`. ``` @@ -195,14 +195,14 @@ switches are most important to you to have implemented next in the new sqlcmd. #### Using :serverlist in batch scripts -When automating server discovery, you can capture the output and check for errors: +When automating server discovery, capture output and errors separately: ```batch @echo off REM Discover local SQL Server instances and connect to the first one -sqlcmd -Q ":serverlist" 2>nul > servers.txt -if %errorlevel% neq 0 ( - echo Error discovering servers +sqlcmd -Q ":serverlist" 2> errors.log > servers.txt +if exist errors.log for %%I in (errors.log) do if %%~zI gtr 0 ( + type errors.log exit /b 1 ) for /f "tokens=1" %%s in (servers.txt) do ( @@ -214,12 +214,6 @@ echo No SQL Server instances found :done ``` -To capture stderr separately (for error logging): -```batch -sqlcmd -Q ":serverlist" 2>errors.log > servers.txt -if exist errors.log for %%I in (errors.log) do if %%~zI gtr 0 type errors.log -``` - ``` 1> select session_id, client_interface_name, program_name from sys.dm_exec_sessions where session_id=@@spid 2> go diff --git a/pkg/sqlcmd/commands_test.go b/pkg/sqlcmd/commands_test.go index f59f88b0..75c8807f 100644 --- a/pkg/sqlcmd/commands_test.go +++ b/pkg/sqlcmd/commands_test.go @@ -490,5 +490,5 @@ func TestHelpCommand(t *testing.T) { assert.Contains(t, output, ":error", "help should list :error") assert.Contains(t, output, ":r", "help should list :r") assert.Contains(t, output, ":serverlist", "help should list :serverlist") - assert.Contains(t, output, "go", "help should list go") + assert.Contains(t, output, "go []", "help should list go") } From 5ae47e90d88a7af1d28d2120a6073c3772bfe1ef Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 4 Sep 2026 16:35:27 -0500 Subject: [PATCH 08/17] fix: return server list write errors Propagate output failures such as broken pipes and cover the error path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/sqlcmd/serverlist.go | 4 +++- pkg/sqlcmd/serverlist_test.go | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/pkg/sqlcmd/serverlist.go b/pkg/sqlcmd/serverlist.go index 67eeb1e6..f4562ea6 100644 --- a/pkg/sqlcmd/serverlist.go +++ b/pkg/sqlcmd/serverlist.go @@ -28,7 +28,9 @@ func ListLocalServers(w io.Writer) error { return err } for _, s := range instances { - _, _ = fmt.Fprintf(w, " %s\n", s) + if _, err := fmt.Fprintf(w, " %s\n", s); err != nil { + return err + } } return nil } diff --git a/pkg/sqlcmd/serverlist_test.go b/pkg/sqlcmd/serverlist_test.go index be913c68..6a61a642 100644 --- a/pkg/sqlcmd/serverlist_test.go +++ b/pkg/sqlcmd/serverlist_test.go @@ -14,6 +14,14 @@ import ( "github.com/stretchr/testify/assert" ) +type failingWriter struct { + err error +} + +func (w failingWriter) Write([]byte) (int, error) { + return 0, w.err +} + func TestListLocalServers(t *testing.T) { original := getLocalServerInstances getLocalServerInstances = func() ([]string, error) { @@ -25,6 +33,9 @@ func TestListLocalServers(t *testing.T) { assert.NoError(t, ListLocalServers(&buf)) assert.Equal(t, " MYSERVER\\SQL2019\n MYSERVER\\SQL2022\n", buf.String()) + + writeErr := errors.New("write failed") + assert.ErrorIs(t, ListLocalServers(failingWriter{err: writeErr}), writeErr) } func TestParseInstances(t *testing.T) { From 67e52f03db31e722431e217ee72b4e3399ff9746 Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 4 Sep 2026 16:45:05 -0500 Subject: [PATCH 09/17] fix: use platform line endings for server lists Match existing sqlcmd output conventions and keep server-list tests portable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/sqlcmd/serverlist.go | 2 +- pkg/sqlcmd/serverlist_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/sqlcmd/serverlist.go b/pkg/sqlcmd/serverlist.go index f4562ea6..e70ec901 100644 --- a/pkg/sqlcmd/serverlist.go +++ b/pkg/sqlcmd/serverlist.go @@ -28,7 +28,7 @@ func ListLocalServers(w io.Writer) error { return err } for _, s := range instances { - if _, err := fmt.Fprintf(w, " %s\n", s); err != nil { + if _, err := fmt.Fprintf(w, " %s%s", s, SqlcmdEol); err != nil { return err } } diff --git a/pkg/sqlcmd/serverlist_test.go b/pkg/sqlcmd/serverlist_test.go index 6a61a642..2c79e749 100644 --- a/pkg/sqlcmd/serverlist_test.go +++ b/pkg/sqlcmd/serverlist_test.go @@ -32,7 +32,7 @@ func TestListLocalServers(t *testing.T) { var buf bytes.Buffer assert.NoError(t, ListLocalServers(&buf)) - assert.Equal(t, " MYSERVER\\SQL2019\n MYSERVER\\SQL2022\n", buf.String()) + assert.Equal(t, " MYSERVER\\SQL2019"+SqlcmdEol+" MYSERVER\\SQL2022"+SqlcmdEol, buf.String()) writeErr := errors.New("write failed") assert.ErrorIs(t, ListLocalServers(failingWriter{err: writeErr}), writeErr) @@ -110,5 +110,5 @@ func TestServerlistCommand(t *testing.T) { err := serverlistCommand(s, []string{""}, 1) assert.NoError(t, err) - assert.Equal(t, " MYSERVER\\SQL2019\n", buf.buf.String()) + assert.Equal(t, " MYSERVER\\SQL2019"+SqlcmdEol, buf.buf.String()) } From ed449a17f6cec216c6c7ab7df392be8ce1fc0a50 Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 4 Sep 2026 16:53:04 -0500 Subject: [PATCH 10/17] fix: shorten server discovery timeout Limit best-effort SQL Browser discovery to five seconds for interactive use. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/sqlcmd/serverlist.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/sqlcmd/serverlist.go b/pkg/sqlcmd/serverlist.go index e70ec901..c70a8cbf 100644 --- a/pkg/sqlcmd/serverlist.go +++ b/pkg/sqlcmd/serverlist.go @@ -18,6 +18,8 @@ import ( "github.com/microsoft/go-mssqldb/msdsn" ) +const serverListTimeout = 5 * time.Second + var getLocalServerInstances = GetLocalServerInstances // ListLocalServers queries the SQL Browser service for available SQL Server instances @@ -42,7 +44,7 @@ func GetLocalServerInstances() ([]string, error) { bmsg := []byte{byte(msdsn.BrowserAllInstances)} resp := make([]byte, 16*1024-1) dialer := &net.Dialer{} - ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + ctx, cancel := context.WithTimeout(context.Background(), serverListTimeout) defer cancel() conn, err := dialer.DialContext(ctx, "udp", ":1434") // silently ignore failures to connect, same as ODBC From 70bb344d75511b14eb77384b24843af8f1bf41c9 Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 4 Sep 2026 17:00:38 -0500 Subject: [PATCH 11/17] fix: handle unavailable browser responses Treat UDP connection resets as unavailable and route server-list errors through the sqlcmd error writer. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/sqlcmd/commands.go | 2 +- pkg/sqlcmd/serverlist.go | 4 +++- pkg/sqlcmd/serverlist_test.go | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/pkg/sqlcmd/commands.go b/pkg/sqlcmd/commands.go index 5865bc41..1db6ea08 100644 --- a/pkg/sqlcmd/commands.go +++ b/pkg/sqlcmd/commands.go @@ -662,7 +662,7 @@ func serverlistCommand(s *Sqlcmd, args []string, line uint) error { return InvalidCommandError("SERVERLIST", line) } if err := ListLocalServers(s.GetOutput()); err != nil { - _, _ = fmt.Fprintln(s.GetError(), err) + s.WriteError(s.GetError(), err) } return nil } diff --git a/pkg/sqlcmd/serverlist.go b/pkg/sqlcmd/serverlist.go index c70a8cbf..da015858 100644 --- a/pkg/sqlcmd/serverlist.go +++ b/pkg/sqlcmd/serverlist.go @@ -74,7 +74,9 @@ func GetLocalServerInstances() ([]string, error) { } func isBrowserUnavailableError(err error) bool { - return errors.Is(err, os.ErrDeadlineExceeded) || errors.Is(err, syscall.ECONNREFUSED) + return errors.Is(err, os.ErrDeadlineExceeded) || + errors.Is(err, syscall.ECONNREFUSED) || + errors.Is(err, syscall.ECONNRESET) } func localServerInstanceNames(data msdsn.BrowserData) []string { diff --git a/pkg/sqlcmd/serverlist_test.go b/pkg/sqlcmd/serverlist_test.go index 2c79e749..7190156f 100644 --- a/pkg/sqlcmd/serverlist_test.go +++ b/pkg/sqlcmd/serverlist_test.go @@ -91,6 +91,7 @@ func TestLocalServerInstanceNamesSkipsMissingServerNames(t *testing.T) { func TestIsBrowserUnavailableError(t *testing.T) { assert.True(t, isBrowserUnavailableError(fmt.Errorf("browser unavailable: %w", syscall.ECONNREFUSED))) + assert.True(t, isBrowserUnavailableError(fmt.Errorf("browser unavailable: %w", syscall.ECONNRESET))) assert.False(t, isBrowserUnavailableError(errors.New("network failure"))) } @@ -112,3 +113,20 @@ func TestServerlistCommand(t *testing.T) { assert.NoError(t, err) assert.Equal(t, " MYSERVER\\SQL2019"+SqlcmdEol, buf.buf.String()) } + +func TestServerlistCommandWritesErrors(t *testing.T) { + discoveryErr := errors.New("network failure") + original := getLocalServerInstances + getLocalServerInstances = func() ([]string, error) { + return nil, discoveryErr + } + defer func() { getLocalServerInstances = original }() + + s := New(nil, "", InitializeVariables(false)) + errBuf := &memoryBuffer{buf: new(bytes.Buffer)} + s.SetError(errBuf) + defer func() { _ = errBuf.Close() }() + + assert.NoError(t, serverlistCommand(s, []string{""}, 1)) + assert.Equal(t, discoveryErr.Error()+SqlcmdEol, errBuf.buf.String()) +} From adf44d64a37995d5e1796a39655e08e52ab3cd07 Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 4 Sep 2026 17:07:19 -0500 Subject: [PATCH 12/17] fix: route server list errors to stderr Wrap discovery failures as sqlcmd errors so default output uses stderr while configured error redirection is preserved. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/sqlcmd/commands.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/sqlcmd/commands.go b/pkg/sqlcmd/commands.go index 1db6ea08..8ba6968f 100644 --- a/pkg/sqlcmd/commands.go +++ b/pkg/sqlcmd/commands.go @@ -662,7 +662,7 @@ func serverlistCommand(s *Sqlcmd, args []string, line uint) error { return InvalidCommandError("SERVERLIST", line) } if err := ListLocalServers(s.GetOutput()); err != nil { - s.WriteError(s.GetError(), err) + s.WriteError(s.GetError(), &CommonSqlcmdErr{message: err.Error()}) } return nil } From 3253d9d3cc45aef39274966ad6720c8e377ebaf8 Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 4 Sep 2026 17:12:08 -0500 Subject: [PATCH 13/17] fix: retain unterminated browser response Emit a fully parsed final instance even when the SQL Browser payload omits its trailing delimiter. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/sqlcmd/serverlist.go | 12 +++++++++--- pkg/sqlcmd/serverlist_test.go | 9 +++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/pkg/sqlcmd/serverlist.go b/pkg/sqlcmd/serverlist.go index da015858..435ae419 100644 --- a/pkg/sqlcmd/serverlist.go +++ b/pkg/sqlcmd/serverlist.go @@ -112,6 +112,11 @@ func parseInstances(msg []byte) msdsn.BrowserData { instanceDict := map[string]string{} gotName := false var name string + addInstance := func() { + if instName, ok := instanceDict["InstanceName"]; ok && instName != "" { + results[strings.ToUpper(instName)] = instanceDict + } + } for _, token := range tokens { if gotName { instanceDict[name] = token @@ -123,15 +128,16 @@ func parseInstances(msg []byte) msdsn.BrowserData { break } // Skip malformed responses without a valid instance name. - if instName, ok := instanceDict["InstanceName"]; ok && instName != "" { - results[strings.ToUpper(instName)] = instanceDict - } + addInstance() instanceDict = map[string]string{} continue } gotName = true } } + if !gotName && len(instanceDict) > 0 { + addInstance() + } } return results } diff --git a/pkg/sqlcmd/serverlist_test.go b/pkg/sqlcmd/serverlist_test.go index 7190156f..276fe595 100644 --- a/pkg/sqlcmd/serverlist_test.go +++ b/pkg/sqlcmd/serverlist_test.go @@ -77,6 +77,15 @@ func TestParseInstances(t *testing.T) { assert.Contains(t, result, "MSSQLSERVER") assert.Contains(t, result, "SQLEXPRESS") }) + + t.Run("missing final terminator", func(t *testing.T) { + data := append([]byte{5, 0, 0}, []byte("ServerName;MYSERVER;InstanceName;SQLEXPRESS;tcp;1434")...) + + result := parseInstances(data) + + assert.Equal(t, "MYSERVER", result["SQLEXPRESS"]["ServerName"]) + assert.Equal(t, "1434", result["SQLEXPRESS"]["tcp"]) + }) } func TestLocalServerInstanceNamesSkipsMissingServerNames(t *testing.T) { From 6e2ef50e7336347dda8184fca0ad720e381bf206 Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 4 Sep 2026 17:18:33 -0500 Subject: [PATCH 14/17] fix: normalize server list line endings Use SqlcmdEol for the legacy -L header and error output. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- cmd/sqlcmd/sqlcmd.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/sqlcmd/sqlcmd.go b/cmd/sqlcmd/sqlcmd.go index 2f63cda4..44cdb84c 100644 --- a/cmd/sqlcmd/sqlcmd.go +++ b/cmd/sqlcmd/sqlcmd.go @@ -235,11 +235,11 @@ func Execute(version string) { // emulate -L returning no servers if args.ListServers != "" { if args.ListServers != "c" { - fmt.Println() - fmt.Println(localizer.Sprintf("Servers:")) + fmt.Fprint(os.Stdout, sqlcmd.SqlcmdEol) + fmt.Fprintf(os.Stdout, "%s%s", localizer.Sprintf("Servers:"), sqlcmd.SqlcmdEol) } if err := sqlcmd.ListLocalServers(os.Stdout); err != nil { - fmt.Fprintln(os.Stderr, err) + fmt.Fprintf(os.Stderr, "%s%s", err, sqlcmd.SqlcmdEol) } os.Exit(0) } From a0165f5556076a775fda3301b5fd66888af49879 Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 4 Sep 2026 17:25:24 -0500 Subject: [PATCH 15/17] fix: validate server list output writes Mark intentional header write handling and format discovery errors through their error string. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- cmd/sqlcmd/sqlcmd.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/sqlcmd/sqlcmd.go b/cmd/sqlcmd/sqlcmd.go index 44cdb84c..a7769b58 100644 --- a/cmd/sqlcmd/sqlcmd.go +++ b/cmd/sqlcmd/sqlcmd.go @@ -235,11 +235,11 @@ func Execute(version string) { // emulate -L returning no servers if args.ListServers != "" { if args.ListServers != "c" { - fmt.Fprint(os.Stdout, sqlcmd.SqlcmdEol) - fmt.Fprintf(os.Stdout, "%s%s", localizer.Sprintf("Servers:"), sqlcmd.SqlcmdEol) + _, _ = fmt.Fprint(os.Stdout, sqlcmd.SqlcmdEol) + _, _ = fmt.Fprintf(os.Stdout, "%s%s", localizer.Sprintf("Servers:"), sqlcmd.SqlcmdEol) } if err := sqlcmd.ListLocalServers(os.Stdout); err != nil { - fmt.Fprintf(os.Stderr, "%s%s", err, sqlcmd.SqlcmdEol) + _, _ = fmt.Fprintf(os.Stderr, "%v%s", err, sqlcmd.SqlcmdEol) } os.Exit(0) } From a9ad46eb0fb58d830d384631f95de6d00a39b87d Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 4 Sep 2026 17:33:52 -0500 Subject: [PATCH 16/17] docs: correct interactive command help Document the supported connect flags and use the established setvar placeholder syntax. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/sqlcmd/commands.go | 4 ++-- pkg/sqlcmd/commands_test.go | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/sqlcmd/commands.go b/pkg/sqlcmd/commands.go index 8ba6968f..76cc4a6f 100644 --- a/pkg/sqlcmd/commands.go +++ b/pkg/sqlcmd/commands.go @@ -613,7 +613,7 @@ 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]] +:connect server[\instance] [-l timeout] [-U user [-P password]] [-D database] [-G authentication-method] - Connects to a SQL Server instance. :ed - Edits the current or last executed statement cache. @@ -645,7 +645,7 @@ go [] - Discards the statement cache. :serverlist - Lists local SQL Server instances. -:setvar {variable} +:setvar - Removes a sqlcmd scripting variable. :setvar - Sets a sqlcmd scripting variable. diff --git a/pkg/sqlcmd/commands_test.go b/pkg/sqlcmd/commands_test.go index 75c8807f..5f498534 100644 --- a/pkg/sqlcmd/commands_test.go +++ b/pkg/sqlcmd/commands_test.go @@ -491,4 +491,7 @@ func TestHelpCommand(t *testing.T) { assert.Contains(t, output, ":r", "help should list :r") assert.Contains(t, output, ":serverlist", "help should list :serverlist") assert.Contains(t, output, "go []", "help should list go") + assert.Contains(t, output, `:connect server[\instance] [-l timeout] [-U user [-P password]] [-D database] [-G authentication-method]`) + assert.Contains(t, output, ":setvar ") + assert.NotContains(t, output, ":setvar {variable}") } From 137ad3f98eb69dd1bab19652ff779e81f590adf0 Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 4 Sep 2026 17:40:28 -0500 Subject: [PATCH 17/17] fix: honor server list error redirection Default discovery failures to stderr while preserving explicitly configured error streams, including stdout. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/sqlcmd/commands.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/sqlcmd/commands.go b/pkg/sqlcmd/commands.go index 76cc4a6f..3e87dcb9 100644 --- a/pkg/sqlcmd/commands.go +++ b/pkg/sqlcmd/commands.go @@ -662,7 +662,11 @@ func serverlistCommand(s *Sqlcmd, args []string, line uint) error { return InvalidCommandError("SERVERLIST", line) } if err := ListLocalServers(s.GetOutput()); err != nil { - s.WriteError(s.GetError(), &CommonSqlcmdErr{message: err.Error()}) + errorOutput := s.err + if errorOutput == nil { + errorOutput = os.Stderr + } + s.WriteError(errorOutput, err) } return nil }