Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ switches are most important to you to have implemented next in the new sqlcmd.
- `:Connect` now has an optional `-G` parameter to select one of the authentication methods for Azure SQL Database - `SqlAuthentication`, `ActiveDirectoryDefault`, `ActiveDirectoryIntegrated`, `ActiveDirectoryServicePrincipal`, `ActiveDirectoryManagedIdentity`, `ActiveDirectoryPassword`. If `-G` is not provided, either Integrated security or SQL Authentication will be used, dependent on the presence of a `-U` username parameter.
- The new `--driver-logging-level` command line parameter allows you to see traces from the `go-mssqldb` client driver. Use `64` to see all traces.
- Sqlcmd can now print results using a vertical format. Use the new `--vertical` command line option to set it. It's also controlled by the `SQLCMDFORMAT` scripting variable.
- `:help` displays a list of available sqlcmd commands.

```
1> select session_id, client_interface_name, program_name from sys.dm_exec_sessions where session_id=@@spid
Expand All @@ -183,6 +184,14 @@ client_interface_name go-mssqldb
program_name sqlcmd
```

- `:perftrace` redirects performance statistics output to a file, stderr, or stdout. Requires the `-p` flag (print statistics) to produce timing data.

```
1> :perftrace c:/logs/perf.txt
1> select 1
2> go
```

- `sqlcmd` supports shared memory and named pipe transport. Use the appropriate protocol prefix on the server name to force a protocol:
* `lpc` for shared memory, only for a localhost. `sqlcmd -S lpc:.`
* `np` for named pipes. Or use the UNC named pipe path as the server name: `sqlcmd -S \\myserver\pipe\sql\query`
Expand Down
1 change: 1 addition & 0 deletions cmd/sqlcmd/sqlcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,7 @@ func run(vars *sqlcmd.Variables, args *SQLCmdArguments) (int, error) {
}
s.SetOutput(nil)
s.SetError(nil)
s.SetStat(nil)
return s.Exitcode, err
}

Expand Down
130 changes: 94 additions & 36 deletions pkg/sqlcmd/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package sqlcmd
import (
"flag"
"fmt"
"io"
"os"
"regexp"
"sort"
Expand All @@ -29,6 +30,9 @@ type Command struct {
name string
// whether the command is a system command
isSystem bool
// help is the text shown by :help for this command.
// Multiple lines are allowed. Empty means hidden from :help.
help string
}

// Commands is the set of sqlcmd command implementations
Expand All @@ -41,77 +45,107 @@ func newCommands() Commands {
regex: regexp.MustCompile(`(?im)^[\t ]*?:?EXIT([\( \t]+.*\)*$|$)`),
action: exitCommand,
name: "EXIT",
help: ":exit\n - Quits sqlcmd immediately.\n" +
":exit()\n - Execute statement cache; quit with no return value.\n" +
":exit(<query>)\n - Execute the specified query; returns numeric result.\n",
},
"QUIT": {
regex: regexp.MustCompile(`(?im)^[\t ]*?:?QUIT(?:[ \t]+(.*$)|$)`),
action: quitCommand,
name: "QUIT",
help: ":quit\n - Quits sqlcmd immediately.\n",
},
"GO": {
regex: regexp.MustCompile(batchTerminatorRegex("GO")),
action: goCommand,
name: "GO",
help: "go [<n>]\n - Executes the statement cache (n times).\n",
},
"OUT": {
regex: regexp.MustCompile(`(?im)^[ \t]*:OUT(?:[ \t]+(.*$)|$)`),
action: outCommand,
name: "OUT",
help: ":out <filename>|stderr|stdout\n - Redirects query output to a file, stderr, or stdout.\n",
},
"ERROR": {
regex: regexp.MustCompile(`(?im)^[ \t]*:ERROR(?:[ \t]+(.*$)|$)`),
action: errorCommand,
name: "ERROR",
help: ":error <dest>\n - Redirects error output to a file, stderr, or stdout.\n",
}, "READFILE": {
regex: regexp.MustCompile(`(?im)^[ \t]*:R(?:[ \t]+(.*$)|$)`),
action: readFileCommand,
name: "READFILE",
help: ":r <filename>\n - Append file contents to the statement cache.\n",
},
"SETVAR": {
regex: regexp.MustCompile(`(?im)^[ \t]*:SETVAR(?:[ \t]+(.*$)|$)`),
action: setVarCommand,
name: "SETVAR",
help: ":setvar {variable}\n - Removes a sqlcmd scripting variable.\n" +
":setvar <variable> <value>\n - Sets a sqlcmd scripting variable.\n",
},
"LISTVAR": {
regex: regexp.MustCompile(`(?im)^[\t ]*?:LISTVAR(?:[ \t]+(.*$)|$)`),
action: listVarCommand,
name: "LISTVAR",
help: ":listvar\n - Lists the set sqlcmd scripting variables.\n",
},
"RESET": {
regex: regexp.MustCompile(`(?im)^[ \t]*?:?RESET(?:[ \t]+(.*$)|$)`),
action: resetCommand,
name: "RESET",
help: ":reset\n - Discards the statement cache.\n",
},
"LIST": {
regex: regexp.MustCompile(`(?im)^[ \t]*:LIST(?:[ \t]+(.*$)|$)`),
action: listCommand,
name: "LIST",
help: ":list\n - Prints the content of the statement cache.\n",
},
"CONNECT": {
regex: regexp.MustCompile(`(?im)^[ \t]*:CONNECT(?:[ \t]+(.*$)|$)`),
action: connectCommand,
name: "CONNECT",
help: ":connect server[\\instance] [-l timeout] [-U user [-P password]]\n - Connects to a SQL Server instance.\n",
},
"EXEC": {
regex: regexp.MustCompile(`(?im)^[ \t]*?:?!!(.*$)`),
action: execCommand,
name: "EXEC",
isSystem: true,
help: ":!! [<command>]\n - Executes a command in the operating system shell.\n",
},
"EDIT": {
regex: regexp.MustCompile(`(?im)^[\t ]*?:?ED(?:[ \t]+(.*$)|$)`),
action: editCommand,
name: "EDIT",
isSystem: true,
help: ":ed\n - Edits the current or last executed statement cache.\n",
},
"ONERROR": {
regex: regexp.MustCompile(`(?im)^[\t ]*?:?ON ERROR(?:[ \t]+(.*$)|$)`),
action: onerrorCommand,
name: "ONERROR",
help: ":on error [exit|ignore]\n - Action for batch or sqlcmd command errors.\n",
},
"XML": {
regex: regexp.MustCompile(`(?im)^[\t ]*?:XML(?:[ \t]+(.*$)|$)`),
action: xmlCommand,
name: "XML",
help: ":xml [on|off]\n - Sets XML output mode.\n",
},
"HELP": {
regex: regexp.MustCompile(`(?im)^[ \t]*:HELP(?:[ \t]+(.*$)|$)`),
action: helpCommand,
name: "HELP",
help: ":help\n - Shows this list of commands.\n",
},
"PERFTRACE": {
regex: regexp.MustCompile(`(?im)^[ \t]*:PERFTRACE(?:[ \t]+(.*$)|$)`),
action: perftraceCommand,
name: "PERFTRACE",
help: ":perftrace <filename>|stderr|stdout\n - Redirects timing output to a file, stderr, or stdout.\n",
},
}
}
Expand Down Expand Up @@ -300,61 +334,48 @@ func goCommand(s *Sqlcmd, args []string, line uint) error {
return nil
}

// outCommand changes the output writer to use a file
func outCommand(s *Sqlcmd, args []string, line uint) error {
// redirectWriter resolves a :out/:error/:perftrace argument to stderr, stdout,
// or a new file, then calls setter with the result.
func redirectWriter(s *Sqlcmd, args []string, line uint, name string, setter func(io.WriteCloser)) error {
if len(args) == 0 || args[0] == "" {
return InvalidCommandError("OUT", line)
return InvalidCommandError(name, 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)
setter(os.Stderr)
case strings.EqualFold(filePath, "stdout"):
setter(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])
}
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)
}
setter(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
// outCommand changes the output writer to use a file
func outCommand(s *Sqlcmd, args []string, line uint) error {
if !s.UnicodeOutputFile {
return redirectWriter(s, args, line, "OUT", s.SetOutput)
}
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])
return redirectWriter(s, args, line, "OUT", func(w io.WriteCloser) {
if w != os.Stdout && w != os.Stderr {
win16le := unicode.UTF16(unicode.LittleEndian, unicode.UseBOM)
w = transform.NewWriter(w, win16le.NewEncoder())
}
s.SetError(o)
}
return nil
s.SetOutput(w)
})
}

// errorCommand changes the error writer to use a file
func errorCommand(s *Sqlcmd, args []string, line uint) error {
return redirectWriter(s, args, line, "ERROR", s.SetError)
}

func readFileCommand(s *Sqlcmd, args []string, line uint) error {
Expand Down Expand Up @@ -596,6 +617,43 @@ func xmlCommand(s *Sqlcmd, args []string, line uint) error {
return nil
}

func helpCommand(s *Sqlcmd, args []string, line uint) error {
// :HELP <command> shows help for a single command
if len(args) > 0 && strings.TrimSpace(args[0]) != "" {
key := strings.ToUpper(strings.TrimSpace(args[0]))
if cmd, ok := s.Cmd[key]; ok && cmd.help != "" {
_, err := s.GetOutput().Write([]byte(cmd.help))
return err
}
// Unknown command name -- fall through to full listing
}

// Collect and sort by command name for stable output order
type entry struct {
name string
help string
}
entries := make([]entry, 0, len(s.Cmd))
for _, cmd := range s.Cmd {
if cmd.help != "" {
entries = append(entries, entry{cmd.name, cmd.help})
}
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].help < entries[j].help
})
var b strings.Builder
for _, e := range entries {
b.WriteString(e.help)
}
_, err := s.GetOutput().Write([]byte(b.String()))
return err
}

func perftraceCommand(s *Sqlcmd, args []string, line uint) error {
return redirectWriter(s, args, line, "PERFTRACE", s.SetStat)
}

func resolveArgumentVariables(s *Sqlcmd, arg []rune, failOnUnresolved bool) (string, error) {
var b *strings.Builder
end := len(arg)
Expand Down
100 changes: 100 additions & 0 deletions pkg/sqlcmd/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ func TestCommandParsing(t *testing.T) {
{`:XML ON `, "XML", []string{`ON `}},
{`:RESET`, "RESET", []string{""}},
{`RESET`, "RESET", []string{""}},
{`:HELP`, "HELP", []string{""}},
{`:help`, "HELP", []string{""}},
{`:HELP CONNECT`, "HELP", []string{"CONNECT"}},
{`:help exit`, "HELP", []string{"exit"}},
{`:PERFTRACE stderr`, "PERFTRACE", []string{"stderr"}},
{`:perftrace c:/logs/perf.txt`, "PERFTRACE", []string{"c:/logs/perf.txt"}},
}

for _, test := range commands {
Expand Down Expand Up @@ -464,3 +470,97 @@ func TestExitCommandAppendsParameterToCurrentBatch(t *testing.T) {
}

}

func TestHelpCommand(t *testing.T) {
s := New(nil, "", InitializeVariables(false))
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")

output := buf.buf.String()
// Verify every registered command with a help field appears in output
for name, cmd := range s.Cmd {
if cmd.help != "" {
assert.Contains(t, output, cmd.help,
"help output missing text for command %s", name)
}
}

// :HELP <command> should show only that command's help
buf.buf.Reset()
err = helpCommand(s, []string{"CONNECT"}, 1)
assert.NoError(t, err, "helpCommand CONNECT should not error")
output = buf.buf.String()
assert.Contains(t, output, ":connect", "HELP CONNECT should show connect help")
assert.NotContains(t, output, ":exit", "HELP CONNECT should not show exit help")

// Case-insensitive lookup
buf.buf.Reset()
err = helpCommand(s, []string{"exit"}, 1)
assert.NoError(t, err, "helpCommand exit should not error")
output = buf.buf.String()
assert.Contains(t, output, ":exit", "HELP exit should show exit help")
assert.NotContains(t, output, ":connect", "HELP exit should not show connect help")

// Unknown command falls through to full listing
buf.buf.Reset()
err = helpCommand(s, []string{"NOSUCHCMD"}, 1)
assert.NoError(t, err, "helpCommand unknown should not error")
output = buf.buf.String()
for name, cmd := range s.Cmd {
if cmd.help != "" {
assert.Contains(t, output, cmd.help,
"unknown command should show full help, missing %s", name)
}
}
}

func TestAllCommandsHaveHelp(t *testing.T) {
cmds := newCommands()
for name, cmd := range cmds {
assert.NotEmpty(t, cmd.help,
"command %q has no help text; add a help field to prevent it being hidden from :help", name)
}
}

func TestPerftraceCommand(t *testing.T) {
s := New(nil, "", InitializeVariables(false))
buf := &memoryBuffer{buf: new(bytes.Buffer)}
s.SetOutput(buf)
defer func() { _ = buf.Close() }()

// Test empty argument returns error
err := perftraceCommand(s, []string{""}, 1)
assert.EqualError(t, err, InvalidCommandError("PERFTRACE", 1).Error(), "perftraceCommand with empty argument")

// Test redirect to stdout
err = perftraceCommand(s, []string{"stdout"}, 1)
assert.NoError(t, err, "perftraceCommand with stdout")
assert.Equal(t, os.Stdout, s.GetStat(), "stat set to stdout")

// Test redirect to stderr
err = perftraceCommand(s, []string{"stderr"}, 1)
assert.NoError(t, err, "perftraceCommand with stderr")
assert.Equal(t, os.Stderr, s.GetStat(), "stat set to stderr")

// Test redirect to file
file, err := os.CreateTemp("", "sqlcmdperf")
assert.NoError(t, err, "os.CreateTemp")
defer func() { _ = os.Remove(file.Name()) }()
fileName := file.Name()
_ = file.Close()

err = perftraceCommand(s, []string{fileName}, 1)
assert.NoError(t, err, "perftraceCommand with file path")
// Clean up by setting stat to nil
s.SetStat(nil)

// Test variable resolution
s.vars.Set("myvar", "stdout")
err = perftraceCommand(s, []string{"$(myvar)"}, 1)
assert.NoError(t, err, "perftraceCommand with a variable")
assert.Equal(t, os.Stdout, s.GetStat(), "stat set to stdout using a variable")
}
Loading
Loading