Skip to content

Commit cf4998d

Browse files
Implement -R flag for regional settings
- Add RegionalSettings struct with locale-aware formatting for numbers, currency, dates, and times - Detect user locale from Windows LCID or Unix environment variables - Apply regional formatting to DECIMAL, NUMERIC, MONEY, SMALLMONEY, DATE, TIME, DATETIME, DATETIME2, DATETIMEOFFSET types - Add comprehensive tests for regional formatting - Update README to document the -R flag behavior This enables customers migrating from ODBC sqlcmd to see the same locale-aware output formatting when using the -R flag.
1 parent 170f439 commit cf4998d

8 files changed

Lines changed: 851 additions & 25 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ The `sqlcmd` project aims to be a complete port of the original ODBC sqlcmd to t
122122
- There are new posix-style versions of each flag, such as `--input-file` for `-i`. `sqlcmd -?` will print those parameter names. Those new names do not preserve backward compatibility with ODBC `sqlcmd`. For example, to specify multiple input file names using `--input-file`, the file names must be comma-delimited, not space-delimited.
123123

124124
The following switches have different behavior in this version of `sqlcmd` compared to the original ODBC based `sqlcmd`.
125-
- `-R` switch is ignored. The go runtime does not provide access to user locale information, and it's not readily available through syscall on all supported platforms.
125+
- `-R` switch enables regional formatting for numeric, currency, and date/time values based on the user's locale. Formatting includes locale-specific thousand separators for numbers, and locale-specific date/time formats. On Windows, the user's default locale is detected from system settings. On Linux/macOS, the locale is detected from environment variables (`LC_ALL`, `LC_MESSAGES`, `LANG`).
126126
- `-I` switch is ignored; quoted identifiers are always set on. To disable quoted identifier behavior, add `SET QUOTED IDENTIFIER OFF` in your scripts.
127127
- `-N` now takes an optional string value that can be one of `s[trict]`,`t[rue]`,`m[andatory]`, `yes`,`1`, `o[ptional]`,`no`, `0`, `f[alse]`, or `disable` to specify the encryption choice.
128128
- If `-N` is passed but no value is provided, `true` is used.

cmd/sqlcmd/sqlcmd.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ type SQLCmdArguments struct {
8484
TraceFile string
8585
CodePage string
8686
ListCodePages bool
87+
UseRegionalSettings bool
8788
// Keep Help at the end of the list
8889
Help bool
8990
}
@@ -489,7 +490,7 @@ func setFlags(rootCmd *cobra.Command, args *SQLCmdArguments) {
489490
rootCmd.Flags().StringVarP(&args.ListServers, listServers, "L", "", localizer.Sprintf("%s List servers. Pass %s to omit 'Servers:' output.", "-L[c]", "c"))
490491
rootCmd.Flags().BoolVarP(&args.DedicatedAdminConnection, "dedicated-admin-connection", "A", false, localizer.Sprintf("Dedicated administrator connection"))
491492
_ = rootCmd.Flags().BoolP("enable-quoted-identifiers", "I", true, localizer.Sprintf("Provided for backward compatibility. Quoted identifiers are always enabled"))
492-
_ = rootCmd.Flags().BoolP("client-regional-setting", "R", false, localizer.Sprintf("Provided for backward compatibility. Client regional settings are not used"))
493+
rootCmd.Flags().BoolVarP(&args.UseRegionalSettings, "client-regional-setting", "R", false, localizer.Sprintf("Use client regional settings for currency, date, and time formatting"))
493494
_ = rootCmd.Flags().IntP(removeControlCharacters, "k", 0, localizer.Sprintf("%s Remove control characters from output. Pass 1 to substitute a space per character, 2 for a space per consecutive characters", "-k [1|2]"))
494495
rootCmd.Flags().BoolVarP(&args.EchoInput, "echo-input", "e", false, localizer.Sprintf("Echo input"))
495496
rootCmd.Flags().IntVarP(&args.QueryTimeout, "query-timeout", "t", 0, "Query timeout")
@@ -856,7 +857,7 @@ func run(vars *sqlcmd.Variables, args *SQLCmdArguments) (int, error) {
856857
}
857858

858859
s.Connect = &connectConfig
859-
s.Format = sqlcmd.NewSQLCmdDefaultFormatter(args.TrimSpaces, args.getControlCharacterBehavior())
860+
s.Format = sqlcmd.NewSQLCmdDefaultFormatterWithRegional(args.TrimSpaces, args.getControlCharacterBehavior(), args.UseRegionalSettings)
860861
if args.OutputFile != "" {
861862
err = s.RunCommand(s.Cmd["OUT"], []string{args.OutputFile})
862863
if err != nil {

pkg/sqlcmd/format.go

Lines changed: 72 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -85,15 +85,23 @@ type sqlCmdFormatterType struct {
8585
maxColNameLen int
8686
colorizer color.Colorizer
8787
xml bool
88+
regional *RegionalSettings
8889
}
8990

9091
// NewSQLCmdDefaultFormatter returns a Formatter that mimics the original ODBC-based sqlcmd formatter
9192
func NewSQLCmdDefaultFormatter(removeTrailingSpaces bool, ccb ControlCharacterBehavior) Formatter {
93+
return NewSQLCmdDefaultFormatterWithRegional(removeTrailingSpaces, ccb, false)
94+
}
95+
96+
// NewSQLCmdDefaultFormatterWithRegional returns a Formatter with optional regional settings support
97+
// When useRegionalSettings is true, numeric and date/time values are formatted according to the user's locale
98+
func NewSQLCmdDefaultFormatterWithRegional(removeTrailingSpaces bool, ccb ControlCharacterBehavior, useRegionalSettings bool) Formatter {
9299
return &sqlCmdFormatterType{
93100
removeTrailingSpaces: removeTrailingSpaces,
94101
format: "horizontal",
95102
colorizer: color.New(false),
96103
ccb: ccb,
104+
regional: NewRegionalSettings(useRegionalSettings),
97105
}
98106
}
99107

@@ -478,11 +486,12 @@ func (f *sqlCmdFormatterType) scanRow(rows *sql.Rows) ([]string, error) {
478486
if *j == nil {
479487
row[n] = "NULL"
480488
} else {
489+
typeName := f.columnDetails[n].col.DatabaseTypeName()
481490
switch x := (*j).(type) {
482491
case []byte:
483492
if isBinaryDataType(&f.columnDetails[n].col) {
484493
row[n] = decodeBinary(x)
485-
} else if f.columnDetails[n].col.DatabaseTypeName() == "UNIQUEIDENTIFIER" {
494+
} else if typeName == "UNIQUEIDENTIFIER" {
486495
// Unscramble the guid
487496
// see https://github.com/denisenkom/go-mssqldb/issues/56
488497
x[0], x[1], x[2], x[3] = x[3], x[2], x[1], x[0]
@@ -498,28 +507,55 @@ func (f *sqlCmdFormatterType) scanRow(rows *sql.Rows) ([]string, error) {
498507
row[n] = string(x)
499508
}
500509
case string:
501-
row[n] = x
510+
// Apply regional formatting for DECIMAL/NUMERIC/MONEY when represented as string
511+
if f.regional.IsEnabled() {
512+
switch typeName {
513+
case "DECIMAL", "NUMERIC":
514+
row[n] = f.regional.FormatNumber(x)
515+
case "MONEY", "SMALLMONEY":
516+
row[n] = f.regional.FormatMoney(x)
517+
default:
518+
row[n] = x
519+
}
520+
} else {
521+
row[n] = x
522+
}
502523
case time.Time:
503-
// Go lacks any way to get the user's preferred time format or even the system default
504-
switch f.columnDetails[n].col.DatabaseTypeName() {
505-
case "DATE":
506-
row[n] = x.Format("2006-01-02")
507-
case "DATETIME":
508-
row[n] = x.Format(dateTimeFormatString(3, false))
509-
case "DATETIME2":
510-
row[n] = x.Format(dateTimeFormatString(f.columnDetails[n].scale, false))
511-
case "SMALLDATETIME":
512-
row[n] = x.Format(dateTimeFormatString(0, false))
513-
case "DATETIMEOFFSET":
514-
row[n] = x.Format(dateTimeFormatString(f.columnDetails[n].scale, true))
515-
case "TIME":
516-
format := "15:04:05"
517-
if f.columnDetails[n].scale > 0 {
518-
format = fmt.Sprintf("%s.%0*d", format, f.columnDetails[n].scale, 0)
524+
// Apply regional formatting when -R is enabled
525+
if f.regional.IsEnabled() {
526+
switch typeName {
527+
case "DATE":
528+
row[n] = f.regional.FormatDate(x)
529+
case "DATETIME", "DATETIME2", "SMALLDATETIME":
530+
row[n] = f.regional.FormatDateTime(x, f.columnDetails[n].scale, false)
531+
case "DATETIMEOFFSET":
532+
row[n] = f.regional.FormatDateTime(x, f.columnDetails[n].scale, true)
533+
case "TIME":
534+
row[n] = f.regional.FormatTime(x, f.columnDetails[n].scale)
535+
default:
536+
row[n] = x.Format(time.RFC3339)
537+
}
538+
} else {
539+
switch typeName {
540+
case "DATE":
541+
row[n] = x.Format("2006-01-02")
542+
case "DATETIME":
543+
row[n] = x.Format(dateTimeFormatString(3, false))
544+
case "DATETIME2":
545+
row[n] = x.Format(dateTimeFormatString(f.columnDetails[n].scale, false))
546+
case "SMALLDATETIME":
547+
row[n] = x.Format(dateTimeFormatString(0, false))
548+
case "DATETIMEOFFSET":
549+
row[n] = x.Format(dateTimeFormatString(f.columnDetails[n].scale, true))
550+
case "TIME":
551+
format := "15:04:05"
552+
if f.columnDetails[n].scale > 0 {
553+
format = fmt.Sprintf("%s.%0*d", format, f.columnDetails[n].scale, 0)
554+
}
555+
row[n] = x.Format(format)
556+
default:
557+
row[n] = x.Format(time.RFC3339)
519558
}
520-
row[n] = x.Format(format)
521-
default:
522-
row[n] = x.Format(time.RFC3339)
523559
}
524560
case fmt.Stringer:
525561
row[n] = x.String()
@@ -532,7 +568,21 @@ func (f *sqlCmdFormatterType) scanRow(rows *sql.Rows) ([]string, error) {
532568
}
533569
default:
534570
var err error
535-
if row[n], err = fmt.Sprintf("%v", x), nil; err != nil {
571+
val := fmt.Sprintf("%v", x)
572+
// Apply regional formatting for numeric types
573+
if f.regional.IsEnabled() {
574+
switch typeName {
575+
case "DECIMAL", "NUMERIC":
576+
row[n] = f.regional.FormatNumber(val)
577+
case "MONEY", "SMALLMONEY":
578+
row[n] = f.regional.FormatMoney(val)
579+
default:
580+
row[n] = val
581+
}
582+
} else {
583+
row[n] = val
584+
}
585+
if err != nil {
536586
return nil, err
537587
}
538588
}

0 commit comments

Comments
 (0)