Skip to content

Commit 748fec8

Browse files
fix: address hostile review findings for :perftrace and :help
- Generate :help output from registered Command.help fields instead of hardcoded string literal (eliminates drift when commands are added) - Add help field to every registered command in newCommands() - Extract redirectWriter() to deduplicate outCommand/errorCommand/perftraceCommand - Remove DB dependency from TestHelpCommand and TestPerftraceCommand - Add TestAllCommandsHaveHelp to catch commands with missing help text - Strengthen TestHelpCommand to cross-reference registered help fields - Remove stale PR merge note from GetStat() godoc
1 parent 1b43146 commit 748fec8

3 files changed

Lines changed: 81 additions & 114 deletions

File tree

pkg/sqlcmd/commands.go

Lines changed: 60 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package sqlcmd
66
import (
77
"flag"
88
"fmt"
9+
"io"
910
"os"
1011
"regexp"
1112
"sort"
@@ -29,6 +30,9 @@ type Command struct {
2930
name string
3031
// whether the command is a system command
3132
isSystem bool
33+
// help is the text shown by :help for this command.
34+
// Multiple lines are allowed. Empty means hidden from :help.
35+
help string
3236
}
3337

3438
// Commands is the set of sqlcmd command implementations
@@ -41,87 +45,107 @@ func newCommands() Commands {
4145
regex: regexp.MustCompile(`(?im)^[\t ]*?:?EXIT([\( \t]+.*\)*$|$)`),
4246
action: exitCommand,
4347
name: "EXIT",
48+
help: ":exit\n - Quits sqlcmd immediately.\n" +
49+
":exit()\n - Execute statement cache; quit with no return value.\n" +
50+
":exit(<query>)\n - Execute the specified query; returns numeric result.\n",
4451
},
4552
"QUIT": {
4653
regex: regexp.MustCompile(`(?im)^[\t ]*?:?QUIT(?:[ \t]+(.*$)|$)`),
4754
action: quitCommand,
4855
name: "QUIT",
56+
help: ":quit\n - Quits sqlcmd immediately.\n",
4957
},
5058
"GO": {
5159
regex: regexp.MustCompile(batchTerminatorRegex("GO")),
5260
action: goCommand,
5361
name: "GO",
62+
help: "go [<n>]\n - Executes the statement cache (n times).\n",
5463
},
5564
"OUT": {
5665
regex: regexp.MustCompile(`(?im)^[ \t]*:OUT(?:[ \t]+(.*$)|$)`),
5766
action: outCommand,
5867
name: "OUT",
68+
help: ":out <filename>|stderr|stdout\n - Redirects query output to a file, stderr, or stdout.\n",
5969
},
6070
"ERROR": {
6171
regex: regexp.MustCompile(`(?im)^[ \t]*:ERROR(?:[ \t]+(.*$)|$)`),
6272
action: errorCommand,
6373
name: "ERROR",
74+
help: ":error <dest>\n - Redirects error output to a file, stderr, or stdout.\n",
6475
}, "READFILE": {
6576
regex: regexp.MustCompile(`(?im)^[ \t]*:R(?:[ \t]+(.*$)|$)`),
6677
action: readFileCommand,
6778
name: "READFILE",
79+
help: ":r <filename>\n - Append file contents to the statement cache.\n",
6880
},
6981
"SETVAR": {
7082
regex: regexp.MustCompile(`(?im)^[ \t]*:SETVAR(?:[ \t]+(.*$)|$)`),
7183
action: setVarCommand,
7284
name: "SETVAR",
85+
help: ":setvar {variable}\n - Removes a sqlcmd scripting variable.\n" +
86+
":setvar <variable> <value>\n - Sets a sqlcmd scripting variable.\n",
7387
},
7488
"LISTVAR": {
7589
regex: regexp.MustCompile(`(?im)^[\t ]*?:LISTVAR(?:[ \t]+(.*$)|$)`),
7690
action: listVarCommand,
7791
name: "LISTVAR",
92+
help: ":listvar\n - Lists the set sqlcmd scripting variables.\n",
7893
},
7994
"RESET": {
8095
regex: regexp.MustCompile(`(?im)^[ \t]*?:?RESET(?:[ \t]+(.*$)|$)`),
8196
action: resetCommand,
8297
name: "RESET",
98+
help: ":reset\n - Discards the statement cache.\n",
8399
},
84100
"LIST": {
85101
regex: regexp.MustCompile(`(?im)^[ \t]*:LIST(?:[ \t]+(.*$)|$)`),
86102
action: listCommand,
87103
name: "LIST",
104+
help: ":list\n - Prints the content of the statement cache.\n",
88105
},
89106
"CONNECT": {
90107
regex: regexp.MustCompile(`(?im)^[ \t]*:CONNECT(?:[ \t]+(.*$)|$)`),
91108
action: connectCommand,
92109
name: "CONNECT",
110+
help: ":connect server[\\instance] [-l timeout] [-U user [-P password]]\n - Connects to a SQL Server instance.\n",
93111
},
94112
"EXEC": {
95113
regex: regexp.MustCompile(`(?im)^[ \t]*?:?!!(.*$)`),
96114
action: execCommand,
97115
name: "EXEC",
98116
isSystem: true,
117+
help: ":!! [<command>]\n - Executes a command in the operating system shell.\n",
99118
},
100119
"EDIT": {
101120
regex: regexp.MustCompile(`(?im)^[\t ]*?:?ED(?:[ \t]+(.*$)|$)`),
102121
action: editCommand,
103122
name: "EDIT",
104123
isSystem: true,
124+
help: ":ed\n - Edits the current or last executed statement cache.\n",
105125
},
106126
"ONERROR": {
107127
regex: regexp.MustCompile(`(?im)^[\t ]*?:?ON ERROR(?:[ \t]+(.*$)|$)`),
108128
action: onerrorCommand,
109129
name: "ONERROR",
130+
help: ":on error [exit|ignore]\n - Action for batch or sqlcmd command errors.\n",
110131
},
111132
"XML": {
112133
regex: regexp.MustCompile(`(?im)^[\t ]*?:XML(?:[ \t]+(.*$)|$)`),
113134
action: xmlCommand,
114135
name: "XML",
136+
help: ":xml [on|off]\n - Sets XML output mode.\n",
115137
},
116138
"HELP": {
117139
regex: regexp.MustCompile(`(?im)^[ \t]*:HELP(?:[ \t]+(.*$)|$)`),
118140
action: helpCommand,
119141
name: "HELP",
142+
help: ":help\n - Shows this list of commands.\n",
120143
},
121144
"PERFTRACE": {
122145
regex: regexp.MustCompile(`(?im)^[ \t]*:PERFTRACE(?:[ \t]+(.*$)|$)`),
123146
action: perftraceCommand,
124147
name: "PERFTRACE",
148+
help: ":perftrace <filename>|stderr|stdout\n - Redirects timing output to a file, stderr, or stdout.\n",
125149
},
126150
}
127151
}
@@ -310,61 +334,48 @@ func goCommand(s *Sqlcmd, args []string, line uint) error {
310334
return nil
311335
}
312336

313-
// outCommand changes the output writer to use a file
314-
func outCommand(s *Sqlcmd, args []string, line uint) error {
337+
// redirectWriter resolves a :out/:error/:perftrace argument to stderr, stdout,
338+
// or a new file, then calls setter with the result.
339+
func redirectWriter(s *Sqlcmd, args []string, line uint, name string, setter func(io.WriteCloser)) error {
315340
if len(args) == 0 || args[0] == "" {
316-
return InvalidCommandError("OUT", line)
341+
return InvalidCommandError(name, line)
317342
}
318343
filePath, err := resolveArgumentVariables(s, []rune(args[0]), true)
319344
if err != nil {
320345
return err
321346
}
322-
323347
switch {
324-
case strings.EqualFold(filePath, "stdout"):
325-
s.SetOutput(os.Stdout)
326348
case strings.EqualFold(filePath, "stderr"):
327-
s.SetOutput(os.Stderr)
349+
setter(os.Stderr)
350+
case strings.EqualFold(filePath, "stdout"):
351+
setter(os.Stdout)
328352
default:
329353
o, err := os.OpenFile(filePath, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0o644)
330354
if err != nil {
331355
return InvalidFileError(err, args[0])
332356
}
333-
if s.UnicodeOutputFile {
334-
// ODBC sqlcmd doesn't write a BOM but we will.
335-
// Maybe the endian-ness should be configurable.
336-
win16le := unicode.UTF16(unicode.LittleEndian, unicode.UseBOM)
337-
encoder := transform.NewWriter(o, win16le.NewEncoder())
338-
s.SetOutput(encoder)
339-
} else {
340-
s.SetOutput(o)
341-
}
357+
setter(o)
342358
}
343359
return nil
344360
}
345361

346-
// errorCommand changes the error writer to use a file
347-
func errorCommand(s *Sqlcmd, args []string, line uint) error {
348-
if len(args) == 0 || args[0] == "" {
349-
return InvalidCommandError("ERROR", line)
350-
}
351-
filePath, err := resolveArgumentVariables(s, []rune(args[0]), true)
352-
if err != nil {
353-
return err
362+
// outCommand changes the output writer to use a file
363+
func outCommand(s *Sqlcmd, args []string, line uint) error {
364+
if !s.UnicodeOutputFile {
365+
return redirectWriter(s, args, line, "OUT", s.SetOutput)
354366
}
355-
switch {
356-
case strings.EqualFold(filePath, "stderr"):
357-
s.SetError(os.Stderr)
358-
case strings.EqualFold(filePath, "stdout"):
359-
s.SetError(os.Stdout)
360-
default:
361-
o, err := os.OpenFile(filePath, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0o644)
362-
if err != nil {
363-
return InvalidFileError(err, args[0])
367+
return redirectWriter(s, args, line, "OUT", func(w io.WriteCloser) {
368+
if w != os.Stdout && w != os.Stderr {
369+
win16le := unicode.UTF16(unicode.LittleEndian, unicode.UseBOM)
370+
w = transform.NewWriter(w, win16le.NewEncoder())
364371
}
365-
s.SetError(o)
366-
}
367-
return nil
372+
s.SetOutput(w)
373+
})
374+
}
375+
376+
// errorCommand changes the error writer to use a file
377+
func errorCommand(s *Sqlcmd, args []string, line uint) error {
378+
return redirectWriter(s, args, line, "ERROR", s.SetError)
368379
}
369380

370381
func readFileCommand(s *Sqlcmd, args []string, line uint) error {
@@ -607,72 +618,23 @@ func xmlCommand(s *Sqlcmd, args []string, line uint) error {
607618
}
608619

609620
func helpCommand(s *Sqlcmd, args []string, line uint) error {
610-
helpText := `:!! [<command>]
611-
- Executes a command in the operating system shell.
612-
:connect server[\instance] [-l timeout] [-U user [-P password]]
613-
- Connects to a SQL Server instance.
614-
:ed
615-
- Edits the current or last executed statement cache.
616-
:error <dest>
617-
- Redirects error output to a file, stderr, or stdout.
618-
:exit
619-
- Quits sqlcmd immediately.
620-
:exit()
621-
- Execute statement cache; quit with no return value.
622-
:exit(<query>)
623-
- Execute the specified query; returns numeric result.
624-
go [<n>]
625-
- Executes the statement cache (n times).
626-
:help
627-
- Shows this list of commands.
628-
:list
629-
- Prints the content of the statement cache.
630-
:listvar
631-
- Lists the set sqlcmd scripting variables.
632-
:on error [exit|ignore]
633-
- Action for batch or sqlcmd command errors.
634-
:out <filename>|stderr|stdout
635-
- Redirects query output to a file, stderr, or stdout.
636-
:perftrace <filename>|stderr|stdout
637-
- Redirects timing output to a file, stderr, or stdout.
638-
:quit
639-
- Quits sqlcmd immediately.
640-
:r <filename>
641-
- Append file contents to the statement cache.
642-
:reset
643-
- Discards the statement cache.
644-
:setvar {variable}
645-
- Removes a sqlcmd scripting variable.
646-
:setvar <variable> <value>
647-
- Sets a sqlcmd scripting variable.
648-
:xml [on|off]
649-
- Sets XML output mode.
650-
`
651-
_, err := s.GetOutput().Write([]byte(helpText))
621+
entries := make([]string, 0, len(s.Cmd))
622+
for _, cmd := range s.Cmd {
623+
if cmd.help != "" {
624+
entries = append(entries, cmd.help)
625+
}
626+
}
627+
sort.Strings(entries)
628+
var b strings.Builder
629+
for _, entry := range entries {
630+
b.WriteString(entry)
631+
}
632+
_, err := s.GetOutput().Write([]byte(b.String()))
652633
return err
653634
}
654635

655636
func perftraceCommand(s *Sqlcmd, args []string, line uint) error {
656-
if len(args) == 0 || args[0] == "" {
657-
return InvalidCommandError("PERFTRACE", line)
658-
}
659-
filePath, err := resolveArgumentVariables(s, []rune(args[0]), true)
660-
if err != nil {
661-
return err
662-
}
663-
switch {
664-
case strings.EqualFold(filePath, "stderr"):
665-
s.SetStat(os.Stderr)
666-
case strings.EqualFold(filePath, "stdout"):
667-
s.SetStat(os.Stdout)
668-
default:
669-
o, err := os.OpenFile(filePath, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0o644)
670-
if err != nil {
671-
return InvalidFileError(err, args[0])
672-
}
673-
s.SetStat(o)
674-
}
675-
return nil
637+
return redirectWriter(s, args, line, "PERFTRACE", s.SetStat)
676638
}
677639

678640
func resolveArgumentVariables(s *Sqlcmd, arg []rune, failOnUnresolved bool) (string, error) {

pkg/sqlcmd/commands_test.go

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -464,29 +464,36 @@ func TestExitCommandAppendsParameterToCurrentBatch(t *testing.T) {
464464
}
465465

466466
func TestHelpCommand(t *testing.T) {
467-
s, buf := setupSqlCmdWithMemoryOutput(t)
468-
defer func() { _ = buf.Close() }()
467+
s := New(nil, "", InitializeVariables(false))
468+
buf := &memoryBuffer{buf: new(bytes.Buffer)}
469469
s.SetOutput(buf)
470+
defer func() { _ = buf.Close() }()
470471

471472
err := helpCommand(s, []string{""}, 1)
472473
assert.NoError(t, err, "helpCommand should not error")
473474

474475
output := buf.buf.String()
475-
// Verify key commands are listed
476-
assert.Contains(t, output, ":connect", "help should list :connect")
477-
assert.Contains(t, output, ":exit", "help should list :exit")
478-
assert.Contains(t, output, ":help", "help should list :help")
479-
assert.Contains(t, output, ":setvar", "help should list :setvar")
480-
assert.Contains(t, output, ":listvar", "help should list :listvar")
481-
assert.Contains(t, output, ":out", "help should list :out")
482-
assert.Contains(t, output, ":error", "help should list :error")
483-
assert.Contains(t, output, ":perftrace", "help should list :perftrace")
484-
assert.Contains(t, output, ":r", "help should list :r")
485-
assert.Contains(t, output, "go", "help should list go")
476+
// Verify every registered command with a help field appears in output
477+
for name, cmd := range s.Cmd {
478+
if cmd.help != "" {
479+
assert.Contains(t, output, cmd.help,
480+
"help output missing text for command %s", name)
481+
}
482+
}
483+
}
484+
485+
func TestAllCommandsHaveHelp(t *testing.T) {
486+
cmds := newCommands()
487+
for name, cmd := range cmds {
488+
assert.NotEmpty(t, cmd.help,
489+
"command %q has no help text; add a help field to prevent it being hidden from :help", name)
490+
}
486491
}
487492

488493
func TestPerftraceCommand(t *testing.T) {
489-
s, buf := setupSqlCmdWithMemoryOutput(t)
494+
s := New(nil, "", InitializeVariables(false))
495+
buf := &memoryBuffer{buf: new(bytes.Buffer)}
496+
s.SetOutput(buf)
490497
defer func() { _ = buf.Close() }()
491498

492499
// Test empty argument returns error

pkg/sqlcmd/sqlcmd.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,8 +239,6 @@ func (s *Sqlcmd) SetError(e io.WriteCloser) {
239239

240240
// GetStat returns the io.Writer to use for performance statistics.
241241
// Falls back to GetOutput() when no :perftrace redirection is active.
242-
// After merging PR #631 (print-statistics), update the printStatistics
243-
// call site from s.GetOutput() to s.GetStat().
244242
func (s *Sqlcmd) GetStat() io.Writer {
245243
if s.stat == nil {
246244
return s.GetOutput()

0 commit comments

Comments
 (0)