Skip to content

Commit f479c2a

Browse files
authored
implement vertical format option (#54)
1 parent 71d3253 commit f479c2a

File tree

9 files changed

+141
-35
lines changed

9 files changed

+141
-35
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ We will be implementing command line switches and behaviors over time. Several s
3030

3131
- `: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` user name parameter.
3232
- 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.
33+
- Sqlcmd can now print results using a vertical format. Use the new `-F vertical` command line option to set it. It's also controlled by the `SQLCMDFORMAT` scripting variable.
3334

3435
### Azure Active Directory Authentication
3536

cmd/sqlcmd/main.go

+2
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ type SQLCmdArguments struct {
4747
ExitOnError bool `short:"b" help:"Specifies that sqlcmd exits and returns a DOS ERRORLEVEL value when an error occurs."`
4848
ErrorSeverityLevel uint8 `short:"V" help:"Controls the severity level that is used to set the ERRORLEVEL variable on exit."`
4949
ErrorLevel int `short:"m" help:"Controls which error messages are sent to stdout. Messages that have severity level greater than or equal to this level are sent."`
50+
Format string `short:"F" help:"Specifies the formatting for results." default:"horiz" enum:"horiz,horizontal,vert,vertical"`
5051
}
5152

5253
// Validate accounts for settings not described by Kong attributes
@@ -141,6 +142,7 @@ func setVars(vars *sqlcmd.Variables, args *SQLCmdArguments) {
141142
sqlcmd.SQLCMDCOLWIDTH: func(a *SQLCmdArguments) string { return "" },
142143
sqlcmd.SQLCMDMAXVARTYPEWIDTH: func(a *SQLCmdArguments) string { return "" },
143144
sqlcmd.SQLCMDMAXFIXEDTYPEWIDTH: func(a *SQLCmdArguments) string { return "" },
145+
sqlcmd.SQLCMDFORMAT: func(a *SQLCmdArguments) string { return a.Format },
144146
}
145147
for varname, set := range varmap {
146148
val := set(args)

cmd/sqlcmd/main_test.go

+4
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ func TestValidCommandLineToArgsConversion(t *testing.T) {
7070
{[]string{"-b", "-m", "15", "-V", "20"}, func(args SQLCmdArguments) bool {
7171
return args.ExitOnError && args.ErrorLevel == 15 && args.ErrorSeverityLevel == 20
7272
}},
73+
{[]string{"-F", "vert"}, func(args SQLCmdArguments) bool {
74+
return args.Format == "vert"
75+
}},
7376
}
7477

7578
for _, test := range commands {
@@ -96,6 +99,7 @@ func TestInvalidCommandLine(t *testing.T) {
9699
{[]string{"-E", "-U", "someuser"}, "--use-trusted-connection and --user-name can't be used together"},
97100
// the test prefix is a kong artifact https://github.com/alecthomas/kong/issues/221
98101
{[]string{"-a", "100"}, "test: '-a 100': Packet size has to be a number between 512 and 32767."},
102+
{[]string{"-F", "what"}, "--format must be one of \"horiz\",\"horizontal\",\"vert\",\"vertical\" but got \"what\""},
99103
}
100104

101105
for _, test := range commands {

go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ require (
66
github.com/alecthomas/kong v0.2.18-0.20210621093454-54558f65e86f
77
github.com/denisenkom/go-mssqldb v0.12.0
88
github.com/golang-sql/sqlexp v0.0.0-20170517235910-f1bb20e5a188
9-
github.com/google/uuid v1.2.0
9+
github.com/google/uuid v1.3.0
1010
github.com/peterh/liner v1.2.2
1111
github.com/stretchr/testify v1.7.0
1212
)

go.sum

+2-2
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZ
1414
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
1515
github.com/golang-sql/sqlexp v0.0.0-20170517235910-f1bb20e5a188 h1:+eHOFJl1BaXrQxKX+T06f78590z4qA2ZzBTqahsKSE4=
1616
github.com/golang-sql/sqlexp v0.0.0-20170517235910-f1bb20e5a188/go.mod h1:vXjM/+wXQnTPR4KqTKDgJukSZ6amVRtWMPEjE6sQoK8=
17-
github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs=
18-
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
17+
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
18+
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
1919
github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4=
2020
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
2121
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=

pkg/sqlcmd/format.go

+71-29
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"time"
1212

1313
mssql "github.com/denisenkom/go-mssqldb"
14+
"github.com/google/uuid"
1415
)
1516

1617
const (
@@ -58,6 +59,8 @@ type columnDetail struct {
5859
}
5960

6061
// The default formatter based on the native sqlcmd style
62+
// It supports both horizontal (default) and vertical layout for results.
63+
// Both vertical and horizontal layouts respect column widths set by SQLCMD variables.
6164
type sqlCmdFormatterType struct {
6265
out io.Writer
6366
err io.Writer
@@ -68,12 +71,15 @@ type sqlCmdFormatterType struct {
6871
columnDetails []columnDetail
6972
rowcount int
7073
writepos int64
74+
format string
75+
maxColNameLen int
7176
}
7277

7378
// NewSQLCmdDefaultFormatter returns a Formatter that mimics the original ODBC-based sqlcmd formatter
7479
func NewSQLCmdDefaultFormatter(removeTrailingSpaces bool) Formatter {
7580
return &sqlCmdFormatterType{
7681
removeTrailingSpaces: removeTrailingSpaces,
82+
format: "horizontal",
7783
}
7884
}
7985

@@ -115,6 +121,7 @@ func (f *sqlCmdFormatterType) BeginBatch(_ string, vars *Variables, out io.Write
115121
f.err = err
116122
f.vars = vars
117123
f.colsep = vars.ColumnSeparator()
124+
f.format = vars.Format()
118125
}
119126

120127
func (f *sqlCmdFormatterType) EndBatch() {
@@ -125,8 +132,8 @@ func (f *sqlCmdFormatterType) EndBatch() {
125132
// base our numbers for most types on https://docs.microsoft.com/sql/odbc/reference/appendixes/column-size
126133
func (f *sqlCmdFormatterType) BeginResultSet(cols []*sql.ColumnType) {
127134
f.rowcount = 0
128-
f.columnDetails = calcColumnDetails(cols, f.vars.MaxFixedColumnWidth(), f.vars.MaxVarColumnWidth())
129-
if f.vars.RowsBetweenHeaders() > -1 {
135+
f.columnDetails, f.maxColNameLen = calcColumnDetails(cols, f.vars.MaxFixedColumnWidth(), f.vars.MaxVarColumnWidth())
136+
if f.vars.RowsBetweenHeaders() > -1 && f.format == "horizontal" {
130137
f.printColumnHeadings()
131138
}
132139
}
@@ -144,25 +151,41 @@ func (f *sqlCmdFormatterType) AddRow(row *sql.Rows) string {
144151
f.mustWriteErr(err.Error())
145152
return retval
146153
}
154+
retval = values[0]
155+
if f.format == "horizontal" {
156+
// values are the full values, look at the displaywidth of each column and truncate accordingly
157+
for i, v := range values {
158+
if i > 0 {
159+
f.writeOut(f.vars.ColumnSeparator())
160+
}
161+
f.printColumnValue(v, i)
162+
}
163+
f.rowcount++
164+
gap := f.vars.RowsBetweenHeaders()
165+
if gap > 0 && (int64(f.rowcount)%gap == 0) {
166+
f.writeOut(SqlcmdEol)
167+
f.printColumnHeadings()
168+
}
169+
} else {
170+
f.addVerticalRow(values)
171+
}
172+
f.writeOut(SqlcmdEol)
173+
return retval
147174

148-
// values are the full values, look at the displaywidth of each column and truncate accordingly
175+
}
176+
177+
func (f *sqlCmdFormatterType) addVerticalRow(values []string) {
149178
for i, v := range values {
150-
if i > 0 {
151-
f.writeOut(f.vars.ColumnSeparator())
152-
} else {
153-
retval = v
179+
if f.vars.RowsBetweenHeaders() > -1 {
180+
builder := new(strings.Builder)
181+
name := f.columnDetails[i].col.Name()
182+
builder.WriteString(name)
183+
builder = padRight(builder, int64(f.maxColNameLen-len(name)+1), " ")
184+
f.writeOut(builder.String())
154185
}
155186
f.printColumnValue(v, i)
156-
}
157-
f.rowcount++
158-
gap := f.vars.RowsBetweenHeaders()
159-
if gap > 0 && (int64(f.rowcount)%gap == 0) {
160187
f.writeOut(SqlcmdEol)
161-
f.printColumnHeadings()
162188
}
163-
f.writeOut(SqlcmdEol)
164-
return retval
165-
166189
}
167190

168191
// Writes a non-error message to the designated message writer
@@ -266,12 +289,17 @@ func fitToScreen(s *strings.Builder, width int64) *strings.Builder {
266289
}
267290

268291
// Given the array of driver-provided columnType values and the sqlcmd size limits,
269-
// return an array of columnDetail objects describing the output format for each column
270-
func calcColumnDetails(cols []*sql.ColumnType, fixed int64, variable int64) (columnDetails []columnDetail) {
271-
columnDetails = make([]columnDetail, len(cols))
292+
// Return an array of columnDetail objects describing the output format for each column.
293+
// Return the length of the longest column name.
294+
func calcColumnDetails(cols []*sql.ColumnType, fixed int64, variable int64) ([]columnDetail, int) {
295+
columnDetails := make([]columnDetail, len(cols))
296+
maxNameLen := 0
272297
for i, c := range cols {
273298
length, _ := c.Length()
274299
nameLen := int64(len([]rune(c.Name())))
300+
if nameLen > int64(maxNameLen) {
301+
maxNameLen = int(nameLen)
302+
}
275303
columnDetails[i].col = *c
276304
columnDetails[i].leftJustify = true
277305
columnDetails[i].zeroesAfterDecimal = false
@@ -381,7 +409,7 @@ func calcColumnDetails(cols []*sql.ColumnType, fixed int64, variable int64) (col
381409
columnDetails[i].displayWidth = 0
382410
}
383411
}
384-
return columnDetails
412+
return columnDetails, maxNameLen
385413
}
386414

387415
// scanRow fetches the next row and converts each value to the appropriate string representation
@@ -403,6 +431,18 @@ func (f *sqlCmdFormatterType) scanRow(rows *sql.Rows) ([]string, error) {
403431
case []byte:
404432
if isBinaryDataType(&f.columnDetails[n].col) {
405433
row[n] = decodeBinary(x)
434+
} else if f.columnDetails[n].col.DatabaseTypeName() == "UNIQUEIDENTIFIER" {
435+
// Unscramble the guid
436+
// see https://github.com/denisenkom/go-mssqldb/issues/56
437+
x[0], x[1], x[2], x[3] = x[3], x[2], x[1], x[0]
438+
x[4], x[5] = x[5], x[4]
439+
x[6], x[7] = x[7], x[6]
440+
if guid, err := uuid.FromBytes(x); err == nil {
441+
row[n] = guid.String()
442+
} else {
443+
// this should never happen
444+
row[n] = uuid.New().String()
445+
}
406446
} else {
407447
row[n] = string(x)
408448
}
@@ -445,20 +485,22 @@ func (f *sqlCmdFormatterType) printColumnValue(val string, col int) {
445485

446486
s.WriteString(val)
447487
r := []rune(val)
448-
if !f.removeTrailingSpaces {
449-
if f.vars.MaxVarColumnWidth() != 0 || !isLargeVariableType(&c.col) {
450-
padding := c.displayWidth - min64(c.displayWidth, int64(len(r)))
451-
if padding > 0 {
452-
if c.leftJustify {
453-
s = padRight(s, padding, " ")
454-
} else {
455-
s = padLeft(s, padding, " ")
488+
if f.format == "horizontal" {
489+
if !f.removeTrailingSpaces {
490+
if f.vars.MaxVarColumnWidth() != 0 || !isLargeVariableType(&c.col) {
491+
padding := c.displayWidth - min64(c.displayWidth, int64(len(r)))
492+
if padding > 0 {
493+
if c.leftJustify {
494+
s = padRight(s, padding, " ")
495+
} else {
496+
s = padLeft(s, padding, " ")
497+
}
456498
}
457499
}
458500
}
459-
}
460501

461-
r = []rune(s.String())
502+
r = []rune(s.String())
503+
}
462504
if c.displayWidth > 0 && int64(len(r)) > c.displayWidth {
463505
s.Reset()
464506
s.WriteString(string(r[:c.displayWidth]))

pkg/sqlcmd/format_test.go

+6-3
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ func TestCalcColumnDetails(t *testing.T) {
4343
variable int64
4444
query string
4545
details []columnDetail
46+
max int
4647
}
4748

4849
tests := []colTest{
@@ -53,25 +54,27 @@ func TestCalcColumnDetails(t *testing.T) {
5354
{leftJustify: false, displayWidth: 23},
5455
{leftJustify: true, displayWidth: 6},
5556
},
57+
12,
5658
},
5759
}
5860

5961
db, err := ConnectDb(t)
6062
if assert.NoError(t, err, "ConnectDB failed") {
6163
defer db.Close()
62-
for _, test := range tests {
64+
for x, test := range tests {
6365
rows, err := db.Query(test.query)
6466
if assert.NoError(t, err, "Query failed: %s", test.query) {
6567
defer rows.Close()
6668
cols, err := rows.ColumnTypes()
6769
if assert.NoError(t, err, "ColumnTypes failed:%s", test.query) {
68-
actual := calcColumnDetails(cols, test.fixed, test.variable)
70+
actual, max := calcColumnDetails(cols, test.fixed, test.variable)
6971
for i, a := range actual {
7072
if test.details[i].displayWidth != a.displayWidth ||
7173
test.details[i].leftJustify != a.leftJustify ||
7274
test.details[i].zeroesAfterDecimal != a.zeroesAfterDecimal {
73-
assert.Failf(t, "", "Incorrect test details for column [%s] in query '%s':%+v", cols[i].Name(), test.query, a)
75+
assert.Failf(t, "", "[%d] Incorrect test details for column [%s] in query '%s':%+v", x, cols[i].Name(), test.query, a)
7476
}
77+
assert.Equal(t, test.max, max, "[%d] Max column name length incorrect", x)
7578
}
7679
}
7780
}

pkg/sqlcmd/sqlcmd_test.go

+43
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ func TestGetRunnableQuery(t *testing.T) {
202202

203203
func TestExitInitialQuery(t *testing.T) {
204204
s, buf := setupSqlCmdWithMemoryOutput(t)
205+
defer buf.Close()
205206
s.Query = "EXIT(SELECT '1200', 2100)"
206207
err := s.Run(true, false)
207208
if assert.NoError(t, err, "s.Run(once = true)") {
@@ -240,6 +241,7 @@ func TestExitCodeSetOnError(t *testing.T) {
240241

241242
func TestSqlCmdExitOnError(t *testing.T) {
242243
s, buf := setupSqlCmdWithMemoryOutput(t)
244+
defer buf.Close()
243245
s.Connect.ExitOnError = true
244246
err := runSqlCmd(t, s, []string{"select 1", "GO", ":setvar", "select 2", "GO"})
245247
o := buf.buf.String()
@@ -248,6 +250,7 @@ func TestSqlCmdExitOnError(t *testing.T) {
248250
assert.Equal(t, 1, s.Exitcode, "s.ExitCode for a syntax error")
249251

250252
s, buf = setupSqlCmdWithMemoryOutput(t)
253+
defer buf.Close()
251254
s.Connect.ExitOnError = true
252255
s.Connect.ErrorSeverityLevel = 15
253256
s.vars.Set(SQLCMDERRORLEVEL, "14")
@@ -353,6 +356,46 @@ func TestPromptForPasswordPositive(t *testing.T) {
353356
}
354357
}
355358

359+
func TestVerticalLayoutNoColumns(t *testing.T) {
360+
s, buf := setupSqlCmdWithMemoryOutput(t)
361+
defer buf.Close()
362+
s.vars.Set(SQLCMDFORMAT, "vert")
363+
_, err := s.runQuery("SELECT 100 as 'column1', 2000 as 'col2', 300")
364+
assert.NoError(t, err, "runQuery failed")
365+
assert.Equal(t,
366+
"100"+SqlcmdEol+"2000"+SqlcmdEol+"300"+SqlcmdEol+SqlcmdEol+SqlcmdEol+oneRowAffected+SqlcmdEol,
367+
buf.buf.String(), "Query without column headers")
368+
}
369+
370+
func TestSelectGuidColumn(t *testing.T) {
371+
s, buf := setupSqlCmdWithMemoryOutput(t)
372+
defer buf.Close()
373+
_, err := s.runQuery("select convert(uniqueidentifier, N'3ddba21e-ff0f-4d24-90b4-f355864d7865')")
374+
assert.NoError(t, err, "runQuery failed")
375+
assert.Equal(t, "3ddba21e-ff0f-4d24-90b4-f355864d7865"+SqlcmdEol+SqlcmdEol+oneRowAffected+SqlcmdEol, buf.buf.String(), "select a uniqueidentifier should work")
376+
}
377+
378+
func TestSelectNullGuidColumn(t *testing.T) {
379+
s, buf := setupSqlCmdWithMemoryOutput(t)
380+
defer buf.Close()
381+
_, err := s.runQuery("select convert(uniqueidentifier,null)")
382+
assert.NoError(t, err, "runQuery failed")
383+
assert.Equal(t, "NULL"+SqlcmdEol+SqlcmdEol+oneRowAffected+SqlcmdEol, buf.buf.String(), "select a null uniqueidentifier should work")
384+
}
385+
386+
func TestVerticalLayoutWithColumns(t *testing.T) {
387+
s, buf := setupSqlCmdWithMemoryOutput(t)
388+
defer buf.Close()
389+
s.vars.Set(SQLCMDFORMAT, "vert")
390+
s.vars.Set(SQLCMDMAXVARTYPEWIDTH, "256")
391+
_, err := s.runQuery("SELECT 100 as 'column1', 2000 as 'col2', 300")
392+
assert.NoError(t, err, "runQuery failed")
393+
assert.Equal(t,
394+
"column1 100"+SqlcmdEol+"col2 2000"+SqlcmdEol+" 300"+SqlcmdEol+SqlcmdEol+SqlcmdEol+oneRowAffected+SqlcmdEol,
395+
buf.buf.String(), "Query without column headers")
396+
397+
}
398+
356399
// runSqlCmd uses lines as input for sqlcmd instead of relying on file or console input
357400
func runSqlCmd(t testing.TB, s *Sqlcmd, lines []string) error {
358401
t.Helper()

pkg/sqlcmd/variables.go

+11
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const (
2828
SQLCMDCOLSEP = "SQLCMDCOLSEP"
2929
SQLCMDCOLWIDTH = "SQLCMDCOLWIDTH"
3030
SQLCMDERRORLEVEL = "SQLCMDERRORLEVEL"
31+
SQLCMDFORMAT = "SQLCMDFORMAT"
3132
SQLCMDMAXVARTYPEWIDTH = "SQLCMDMAXVARTYPEWIDTH"
3233
SQLCMDMAXFIXEDTYPEWIDTH = "SQLCMDMAXFIXEDTYPEWIDTH"
3334
SQLCMDEDITOR = "SQLCMDEDITOR"
@@ -41,6 +42,7 @@ var builtinVariables = []string{
4142
SQLCMDDBNAME,
4243
SQLCMDEDITOR,
4344
SQLCMDERRORLEVEL,
45+
SQLCMDFORMAT,
4446
SQLCMDHEADERS,
4547
SQLCMDINI,
4648
SQLCMDLOGINTIMEOUT,
@@ -168,6 +170,15 @@ func (v Variables) ErrorLevel() int64 {
168170
return mustValue(v[SQLCMDERRORLEVEL])
169171
}
170172

173+
// Format is the name of the results format
174+
func (v Variables) Format() string {
175+
switch v[SQLCMDFORMAT] {
176+
case "vert", "vertical":
177+
return "vertical"
178+
}
179+
return "horizontal"
180+
}
181+
171182
func mustValue(val string) int64 {
172183
var n int64
173184
_, err := fmt.Sscanf(val, "%d", &n)

0 commit comments

Comments
 (0)