diff --git a/README.md b/README.md index 8c9565fc..aa00f9e5 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ We will be implementing command line switches and behaviors over time. Several s - `: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. - 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 `-F vertical` command line option to set it. It's also controlled by the `SQLCMDFORMAT` scripting variable. ### Azure Active Directory Authentication diff --git a/cmd/sqlcmd/main.go b/cmd/sqlcmd/main.go index da9c30c5..fc897e3a 100644 --- a/cmd/sqlcmd/main.go +++ b/cmd/sqlcmd/main.go @@ -47,6 +47,7 @@ type SQLCmdArguments struct { ExitOnError bool `short:"b" help:"Specifies that sqlcmd exits and returns a DOS ERRORLEVEL value when an error occurs."` ErrorSeverityLevel uint8 `short:"V" help:"Controls the severity level that is used to set the ERRORLEVEL variable on exit."` 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."` + Format string `short:"F" help:"Specifies the formatting for results." default:"horiz" enum:"horiz,horizontal,vert,vertical"` } // Validate accounts for settings not described by Kong attributes @@ -141,6 +142,7 @@ func setVars(vars *sqlcmd.Variables, args *SQLCmdArguments) { sqlcmd.SQLCMDCOLWIDTH: func(a *SQLCmdArguments) string { return "" }, sqlcmd.SQLCMDMAXVARTYPEWIDTH: func(a *SQLCmdArguments) string { return "" }, sqlcmd.SQLCMDMAXFIXEDTYPEWIDTH: func(a *SQLCmdArguments) string { return "" }, + sqlcmd.SQLCMDFORMAT: func(a *SQLCmdArguments) string { return a.Format }, } for varname, set := range varmap { val := set(args) diff --git a/cmd/sqlcmd/main_test.go b/cmd/sqlcmd/main_test.go index 0129b1d8..f1cc4949 100644 --- a/cmd/sqlcmd/main_test.go +++ b/cmd/sqlcmd/main_test.go @@ -70,6 +70,9 @@ func TestValidCommandLineToArgsConversion(t *testing.T) { {[]string{"-b", "-m", "15", "-V", "20"}, func(args SQLCmdArguments) bool { return args.ExitOnError && args.ErrorLevel == 15 && args.ErrorSeverityLevel == 20 }}, + {[]string{"-F", "vert"}, func(args SQLCmdArguments) bool { + return args.Format == "vert" + }}, } for _, test := range commands { @@ -96,6 +99,7 @@ func TestInvalidCommandLine(t *testing.T) { {[]string{"-E", "-U", "someuser"}, "--use-trusted-connection and --user-name can't be used together"}, // the test prefix is a kong artifact https://github.com/alecthomas/kong/issues/221 {[]string{"-a", "100"}, "test: '-a 100': Packet size has to be a number between 512 and 32767."}, + {[]string{"-F", "what"}, "--format must be one of \"horiz\",\"horizontal\",\"vert\",\"vertical\" but got \"what\""}, } for _, test := range commands { diff --git a/go.mod b/go.mod index 26ef0462..6936235e 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/alecthomas/kong v0.2.18-0.20210621093454-54558f65e86f github.com/denisenkom/go-mssqldb v0.12.0 github.com/golang-sql/sqlexp v0.0.0-20170517235910-f1bb20e5a188 - github.com/google/uuid v1.2.0 + github.com/google/uuid v1.3.0 github.com/peterh/liner v1.2.2 github.com/stretchr/testify v1.7.0 ) diff --git a/go.sum b/go.sum index 42b2d91f..d909dd49 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZ github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.0.0-20170517235910-f1bb20e5a188 h1:+eHOFJl1BaXrQxKX+T06f78590z4qA2ZzBTqahsKSE4= github.com/golang-sql/sqlexp v0.0.0-20170517235910-f1bb20e5a188/go.mod h1:vXjM/+wXQnTPR4KqTKDgJukSZ6amVRtWMPEjE6sQoK8= -github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs= -github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4= github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= diff --git a/pkg/sqlcmd/format.go b/pkg/sqlcmd/format.go index 28a31b83..a37ead80 100644 --- a/pkg/sqlcmd/format.go +++ b/pkg/sqlcmd/format.go @@ -11,6 +11,7 @@ import ( "time" mssql "github.com/denisenkom/go-mssqldb" + "github.com/google/uuid" ) const ( @@ -58,6 +59,8 @@ type columnDetail struct { } // The default formatter based on the native sqlcmd style +// It supports both horizontal (default) and vertical layout for results. +// Both vertical and horizontal layouts respect column widths set by SQLCMD variables. type sqlCmdFormatterType struct { out io.Writer err io.Writer @@ -68,12 +71,15 @@ type sqlCmdFormatterType struct { columnDetails []columnDetail rowcount int writepos int64 + format string + maxColNameLen int } // NewSQLCmdDefaultFormatter returns a Formatter that mimics the original ODBC-based sqlcmd formatter func NewSQLCmdDefaultFormatter(removeTrailingSpaces bool) Formatter { return &sqlCmdFormatterType{ removeTrailingSpaces: removeTrailingSpaces, + format: "horizontal", } } @@ -115,6 +121,7 @@ func (f *sqlCmdFormatterType) BeginBatch(_ string, vars *Variables, out io.Write f.err = err f.vars = vars f.colsep = vars.ColumnSeparator() + f.format = vars.Format() } func (f *sqlCmdFormatterType) EndBatch() { @@ -125,8 +132,8 @@ func (f *sqlCmdFormatterType) EndBatch() { // base our numbers for most types on https://docs.microsoft.com/sql/odbc/reference/appendixes/column-size func (f *sqlCmdFormatterType) BeginResultSet(cols []*sql.ColumnType) { f.rowcount = 0 - f.columnDetails = calcColumnDetails(cols, f.vars.MaxFixedColumnWidth(), f.vars.MaxVarColumnWidth()) - if f.vars.RowsBetweenHeaders() > -1 { + f.columnDetails, f.maxColNameLen = calcColumnDetails(cols, f.vars.MaxFixedColumnWidth(), f.vars.MaxVarColumnWidth()) + if f.vars.RowsBetweenHeaders() > -1 && f.format == "horizontal" { f.printColumnHeadings() } } @@ -144,25 +151,41 @@ func (f *sqlCmdFormatterType) AddRow(row *sql.Rows) string { f.mustWriteErr(err.Error()) return retval } + retval = values[0] + if f.format == "horizontal" { + // values are the full values, look at the displaywidth of each column and truncate accordingly + for i, v := range values { + if i > 0 { + f.writeOut(f.vars.ColumnSeparator()) + } + f.printColumnValue(v, i) + } + f.rowcount++ + gap := f.vars.RowsBetweenHeaders() + if gap > 0 && (int64(f.rowcount)%gap == 0) { + f.writeOut(SqlcmdEol) + f.printColumnHeadings() + } + } else { + f.addVerticalRow(values) + } + f.writeOut(SqlcmdEol) + return retval - // values are the full values, look at the displaywidth of each column and truncate accordingly +} + +func (f *sqlCmdFormatterType) addVerticalRow(values []string) { for i, v := range values { - if i > 0 { - f.writeOut(f.vars.ColumnSeparator()) - } else { - retval = v + if f.vars.RowsBetweenHeaders() > -1 { + builder := new(strings.Builder) + name := f.columnDetails[i].col.Name() + builder.WriteString(name) + builder = padRight(builder, int64(f.maxColNameLen-len(name)+1), " ") + f.writeOut(builder.String()) } f.printColumnValue(v, i) - } - f.rowcount++ - gap := f.vars.RowsBetweenHeaders() - if gap > 0 && (int64(f.rowcount)%gap == 0) { f.writeOut(SqlcmdEol) - f.printColumnHeadings() } - f.writeOut(SqlcmdEol) - return retval - } // Writes a non-error message to the designated message writer @@ -266,12 +289,17 @@ func fitToScreen(s *strings.Builder, width int64) *strings.Builder { } // Given the array of driver-provided columnType values and the sqlcmd size limits, -// return an array of columnDetail objects describing the output format for each column -func calcColumnDetails(cols []*sql.ColumnType, fixed int64, variable int64) (columnDetails []columnDetail) { - columnDetails = make([]columnDetail, len(cols)) +// Return an array of columnDetail objects describing the output format for each column. +// Return the length of the longest column name. +func calcColumnDetails(cols []*sql.ColumnType, fixed int64, variable int64) ([]columnDetail, int) { + columnDetails := make([]columnDetail, len(cols)) + maxNameLen := 0 for i, c := range cols { length, _ := c.Length() nameLen := int64(len([]rune(c.Name()))) + if nameLen > int64(maxNameLen) { + maxNameLen = int(nameLen) + } columnDetails[i].col = *c columnDetails[i].leftJustify = true columnDetails[i].zeroesAfterDecimal = false @@ -381,7 +409,7 @@ func calcColumnDetails(cols []*sql.ColumnType, fixed int64, variable int64) (col columnDetails[i].displayWidth = 0 } } - return columnDetails + return columnDetails, maxNameLen } // 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) { case []byte: if isBinaryDataType(&f.columnDetails[n].col) { row[n] = decodeBinary(x) + } else if f.columnDetails[n].col.DatabaseTypeName() == "UNIQUEIDENTIFIER" { + // Unscramble the guid + // see https://github.com/denisenkom/go-mssqldb/issues/56 + x[0], x[1], x[2], x[3] = x[3], x[2], x[1], x[0] + x[4], x[5] = x[5], x[4] + x[6], x[7] = x[7], x[6] + if guid, err := uuid.FromBytes(x); err == nil { + row[n] = guid.String() + } else { + // this should never happen + row[n] = uuid.New().String() + } } else { row[n] = string(x) } @@ -445,20 +485,22 @@ func (f *sqlCmdFormatterType) printColumnValue(val string, col int) { s.WriteString(val) r := []rune(val) - if !f.removeTrailingSpaces { - if f.vars.MaxVarColumnWidth() != 0 || !isLargeVariableType(&c.col) { - padding := c.displayWidth - min64(c.displayWidth, int64(len(r))) - if padding > 0 { - if c.leftJustify { - s = padRight(s, padding, " ") - } else { - s = padLeft(s, padding, " ") + if f.format == "horizontal" { + if !f.removeTrailingSpaces { + if f.vars.MaxVarColumnWidth() != 0 || !isLargeVariableType(&c.col) { + padding := c.displayWidth - min64(c.displayWidth, int64(len(r))) + if padding > 0 { + if c.leftJustify { + s = padRight(s, padding, " ") + } else { + s = padLeft(s, padding, " ") + } } } } - } - r = []rune(s.String()) + r = []rune(s.String()) + } if c.displayWidth > 0 && int64(len(r)) > c.displayWidth { s.Reset() s.WriteString(string(r[:c.displayWidth])) diff --git a/pkg/sqlcmd/format_test.go b/pkg/sqlcmd/format_test.go index 877af3a0..0ba31670 100644 --- a/pkg/sqlcmd/format_test.go +++ b/pkg/sqlcmd/format_test.go @@ -43,6 +43,7 @@ func TestCalcColumnDetails(t *testing.T) { variable int64 query string details []columnDetail + max int } tests := []colTest{ @@ -53,25 +54,27 @@ func TestCalcColumnDetails(t *testing.T) { {leftJustify: false, displayWidth: 23}, {leftJustify: true, displayWidth: 6}, }, + 12, }, } db, err := ConnectDb(t) if assert.NoError(t, err, "ConnectDB failed") { defer db.Close() - for _, test := range tests { + for x, test := range tests { rows, err := db.Query(test.query) if assert.NoError(t, err, "Query failed: %s", test.query) { defer rows.Close() cols, err := rows.ColumnTypes() if assert.NoError(t, err, "ColumnTypes failed:%s", test.query) { - actual := calcColumnDetails(cols, test.fixed, test.variable) + actual, max := calcColumnDetails(cols, test.fixed, test.variable) for i, a := range actual { if test.details[i].displayWidth != a.displayWidth || test.details[i].leftJustify != a.leftJustify || test.details[i].zeroesAfterDecimal != a.zeroesAfterDecimal { - assert.Failf(t, "", "Incorrect test details for column [%s] in query '%s':%+v", cols[i].Name(), test.query, a) + assert.Failf(t, "", "[%d] Incorrect test details for column [%s] in query '%s':%+v", x, cols[i].Name(), test.query, a) } + assert.Equal(t, test.max, max, "[%d] Max column name length incorrect", x) } } } diff --git a/pkg/sqlcmd/sqlcmd_test.go b/pkg/sqlcmd/sqlcmd_test.go index fd152420..2d91d410 100644 --- a/pkg/sqlcmd/sqlcmd_test.go +++ b/pkg/sqlcmd/sqlcmd_test.go @@ -202,6 +202,7 @@ func TestGetRunnableQuery(t *testing.T) { func TestExitInitialQuery(t *testing.T) { s, buf := setupSqlCmdWithMemoryOutput(t) + defer buf.Close() s.Query = "EXIT(SELECT '1200', 2100)" err := s.Run(true, false) if assert.NoError(t, err, "s.Run(once = true)") { @@ -240,6 +241,7 @@ func TestExitCodeSetOnError(t *testing.T) { func TestSqlCmdExitOnError(t *testing.T) { s, buf := setupSqlCmdWithMemoryOutput(t) + defer buf.Close() s.Connect.ExitOnError = true err := runSqlCmd(t, s, []string{"select 1", "GO", ":setvar", "select 2", "GO"}) o := buf.buf.String() @@ -248,6 +250,7 @@ func TestSqlCmdExitOnError(t *testing.T) { assert.Equal(t, 1, s.Exitcode, "s.ExitCode for a syntax error") s, buf = setupSqlCmdWithMemoryOutput(t) + defer buf.Close() s.Connect.ExitOnError = true s.Connect.ErrorSeverityLevel = 15 s.vars.Set(SQLCMDERRORLEVEL, "14") @@ -353,6 +356,46 @@ func TestPromptForPasswordPositive(t *testing.T) { } } +func TestVerticalLayoutNoColumns(t *testing.T) { + s, buf := setupSqlCmdWithMemoryOutput(t) + defer buf.Close() + s.vars.Set(SQLCMDFORMAT, "vert") + _, err := s.runQuery("SELECT 100 as 'column1', 2000 as 'col2', 300") + assert.NoError(t, err, "runQuery failed") + assert.Equal(t, + "100"+SqlcmdEol+"2000"+SqlcmdEol+"300"+SqlcmdEol+SqlcmdEol+SqlcmdEol+oneRowAffected+SqlcmdEol, + buf.buf.String(), "Query without column headers") +} + +func TestSelectGuidColumn(t *testing.T) { + s, buf := setupSqlCmdWithMemoryOutput(t) + defer buf.Close() + _, err := s.runQuery("select convert(uniqueidentifier, N'3ddba21e-ff0f-4d24-90b4-f355864d7865')") + assert.NoError(t, err, "runQuery failed") + assert.Equal(t, "3ddba21e-ff0f-4d24-90b4-f355864d7865"+SqlcmdEol+SqlcmdEol+oneRowAffected+SqlcmdEol, buf.buf.String(), "select a uniqueidentifier should work") +} + +func TestSelectNullGuidColumn(t *testing.T) { + s, buf := setupSqlCmdWithMemoryOutput(t) + defer buf.Close() + _, err := s.runQuery("select convert(uniqueidentifier,null)") + assert.NoError(t, err, "runQuery failed") + assert.Equal(t, "NULL"+SqlcmdEol+SqlcmdEol+oneRowAffected+SqlcmdEol, buf.buf.String(), "select a null uniqueidentifier should work") +} + +func TestVerticalLayoutWithColumns(t *testing.T) { + s, buf := setupSqlCmdWithMemoryOutput(t) + defer buf.Close() + s.vars.Set(SQLCMDFORMAT, "vert") + s.vars.Set(SQLCMDMAXVARTYPEWIDTH, "256") + _, err := s.runQuery("SELECT 100 as 'column1', 2000 as 'col2', 300") + assert.NoError(t, err, "runQuery failed") + assert.Equal(t, + "column1 100"+SqlcmdEol+"col2 2000"+SqlcmdEol+" 300"+SqlcmdEol+SqlcmdEol+SqlcmdEol+oneRowAffected+SqlcmdEol, + buf.buf.String(), "Query without column headers") + +} + // runSqlCmd uses lines as input for sqlcmd instead of relying on file or console input func runSqlCmd(t testing.TB, s *Sqlcmd, lines []string) error { t.Helper() diff --git a/pkg/sqlcmd/variables.go b/pkg/sqlcmd/variables.go index 52faf098..fbaed391 100644 --- a/pkg/sqlcmd/variables.go +++ b/pkg/sqlcmd/variables.go @@ -28,6 +28,7 @@ const ( SQLCMDCOLSEP = "SQLCMDCOLSEP" SQLCMDCOLWIDTH = "SQLCMDCOLWIDTH" SQLCMDERRORLEVEL = "SQLCMDERRORLEVEL" + SQLCMDFORMAT = "SQLCMDFORMAT" SQLCMDMAXVARTYPEWIDTH = "SQLCMDMAXVARTYPEWIDTH" SQLCMDMAXFIXEDTYPEWIDTH = "SQLCMDMAXFIXEDTYPEWIDTH" SQLCMDEDITOR = "SQLCMDEDITOR" @@ -41,6 +42,7 @@ var builtinVariables = []string{ SQLCMDDBNAME, SQLCMDEDITOR, SQLCMDERRORLEVEL, + SQLCMDFORMAT, SQLCMDHEADERS, SQLCMDINI, SQLCMDLOGINTIMEOUT, @@ -168,6 +170,15 @@ func (v Variables) ErrorLevel() int64 { return mustValue(v[SQLCMDERRORLEVEL]) } +// Format is the name of the results format +func (v Variables) Format() string { + switch v[SQLCMDFORMAT] { + case "vert", "vertical": + return "vertical" + } + return "horizontal" +} + func mustValue(val string) int64 { var n int64 _, err := fmt.Sscanf(val, "%d", &n)