Skip to content

implement vertical format option #54

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Mar 14, 2022
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions cmd/sqlcmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions cmd/sqlcmd/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
100 changes: 71 additions & 29 deletions pkg/sqlcmd/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

mssql "github.com/denisenkom/go-mssqldb"
"github.com/google/uuid"
)

const (
Expand Down Expand Up @@ -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
Expand All @@ -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",
}
}

Expand Down Expand Up @@ -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() {
Expand All @@ -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()
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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]))
Expand Down
9 changes: 6 additions & 3 deletions pkg/sqlcmd/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func TestCalcColumnDetails(t *testing.T) {
variable int64
query string
details []columnDetail
max int
}

tests := []colTest{
Expand All @@ -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)
}
}
}
Expand Down
43 changes: 43 additions & 0 deletions pkg/sqlcmd/sqlcmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)") {
Expand Down Expand Up @@ -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()
Expand All @@ -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")
Expand Down Expand Up @@ -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()
Expand Down
11 changes: 11 additions & 0 deletions pkg/sqlcmd/variables.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const (
SQLCMDCOLSEP = "SQLCMDCOLSEP"
SQLCMDCOLWIDTH = "SQLCMDCOLWIDTH"
SQLCMDERRORLEVEL = "SQLCMDERRORLEVEL"
SQLCMDFORMAT = "SQLCMDFORMAT"
SQLCMDMAXVARTYPEWIDTH = "SQLCMDMAXVARTYPEWIDTH"
SQLCMDMAXFIXEDTYPEWIDTH = "SQLCMDMAXFIXEDTYPEWIDTH"
SQLCMDEDITOR = "SQLCMDEDITOR"
Expand All @@ -41,6 +42,7 @@ var builtinVariables = []string{
SQLCMDDBNAME,
SQLCMDEDITOR,
SQLCMDERRORLEVEL,
SQLCMDFORMAT,
SQLCMDHEADERS,
SQLCMDINI,
SQLCMDLOGINTIMEOUT,
Expand Down Expand Up @@ -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)
Expand Down