Skip to content

Commit 0ee79e4

Browse files
feat: add --no-bom flag for ODBC sqlcmd compatibility
By default, -u (unicode output) includes a UTF-16 LE BOM (FF FE) at the start of output files. ODBC sqlcmd does not write a BOM. This adds --no-bom flag to omit the BOM when strict ODBC compatibility is needed. Usage: sqlcmd -u --no-bom -o output.txt Changes: - Add NoBOM field to SQLCmdArguments and Sqlcmd structs - Add --no-bom flag with descriptive help - Conditionally use unicode.IgnoreBOM when flag is set - Update README to document the difference and new flag - Add tests for --no-bom flag
1 parent 2138fa7 commit 0ee79e4

File tree

5 files changed

+62
-4
lines changed

5 files changed

+62
-4
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ The following switches have different behavior in this version of `sqlcmd` compa
150150
- If both `-N` and `-C` are provided, sqlcmd will use their values for encryption negotiation.
151151
- To provide the value of the host name in the server certificate when using strict encryption, pass the host name with `-F`. Example: `-Ns -F myhost.domain.com`
152152
- More information about client/server encryption negotiation can be found at <https://docs.microsoft.com/openspecs/windows_protocols/ms-tds/60f56408-0188-4cd5-8b90-25c6f2423868>
153-
- `-u` The generated Unicode output file will have the UTF16 Little-Endian Byte-order mark (BOM) written to it.
153+
- `-u` The generated Unicode output file will have the UTF16 Little-Endian Byte-order mark (BOM) written to it. ODBC sqlcmd does not write a BOM; use `--no-bom` with `-u` if you need strict ODBC compatibility.
154154
- Some behaviors that were kept to maintain compatibility with `OSQL` may be changed, such as alignment of column headers for some data types.
155155
- All commands must fit on one line, even `EXIT`. Interactive mode will not check for open parentheses or quotes for commands and prompt for successive lines. The ODBC sqlcmd allows the query run by `EXIT(query)` to span multiple lines.
156156
- `-i` doesn't handle a comma `,` in a file name correctly unless the file name argument is triple quoted. For example:

cmd/sqlcmd/sqlcmd.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ type SQLCmdArguments struct {
6666
ErrorsToStderr *int
6767
Headers int
6868
UnicodeOutputFile bool
69+
NoBOM bool
6970
Version bool
7071
ColumnSeparator string
7172
ScreenWidth *int
@@ -171,6 +172,8 @@ func (a *SQLCmdArguments) Validate(c *cobra.Command) (err error) {
171172
err = rangeParameterError("-t", fmt.Sprint(a.QueryTimeout), 0, 65534, true)
172173
case a.ServerCertificate != "" && !encryptConnectionAllowsTLS(a.EncryptConnection):
173174
err = localizer.Errorf("The -J parameter requires encryption to be enabled (-N true, -N mandatory, or -N strict).")
175+
case a.NoBOM && !a.UnicodeOutputFile:
176+
err = localizer.Errorf("The --no-bom parameter requires -u (Unicode output file).")
174177
}
175178
}
176179
if err != nil {
@@ -457,6 +460,7 @@ func setFlags(rootCmd *cobra.Command, args *SQLCmdArguments) {
457460
rootCmd.Flags().IntVarP(&args.Headers, "headers", "h", 0, localizer.Sprintf("Specifies the number of rows to print between the column headings. Use -h-1 to specify that headers not be printed"))
458461

459462
rootCmd.Flags().BoolVarP(&args.UnicodeOutputFile, "unicode-output-file", "u", false, localizer.Sprintf("Specifies that all output files are encoded with little-endian Unicode"))
463+
rootCmd.Flags().BoolVar(&args.NoBOM, "no-bom", false, localizer.Sprintf("Omit the UTF-16 BOM from Unicode output files. Use with -u for ODBC sqlcmd compatibility"))
460464
rootCmd.Flags().StringVarP(&args.ColumnSeparator, "column-separator", "s", "", localizer.Sprintf("Specifies the column separator character. Sets the %s variable.", localizer.ColSeparatorVar))
461465
rootCmd.Flags().BoolVarP(&args.TrimSpaces, "trim-spaces", "W", false, localizer.Sprintf("Remove trailing spaces from a column"))
462466
_ = rootCmd.Flags().BoolP("multi-subnet-failover", "M", false, localizer.Sprintf("Provided for backward compatibility. Sqlcmd always optimizes detection of the active replica of a SQL Failover Cluster"))
@@ -816,6 +820,7 @@ func run(vars *sqlcmd.Variables, args *SQLCmdArguments) (int, error) {
816820
s.SetupCloseHandler()
817821
defer s.StopCloseHandler()
818822
s.UnicodeOutputFile = args.UnicodeOutputFile
823+
s.NoBOM = args.NoBOM
819824

820825
if args.DisableCmd != nil {
821826
s.Cmd.DisableSysCommands(args.errorOnBlockedCmd())

cmd/sqlcmd/sqlcmd_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ func TestValidCommandLineToArgsConversion(t *testing.T) {
7575
{[]string{"-u", "-A"}, func(args SQLCmdArguments) bool {
7676
return args.UnicodeOutputFile && args.DedicatedAdminConnection
7777
}},
78+
{[]string{"-u", "--no-bom"}, func(args SQLCmdArguments) bool {
79+
return args.UnicodeOutputFile && args.NoBOM
80+
}},
7881
{[]string{"--version"}, func(args SQLCmdArguments) bool {
7982
return args.Version
8083
}},
@@ -220,6 +223,7 @@ func TestValidateFlags(t *testing.T) {
220223
{[]string{"-a", "100"}, "'-a 100': Packet size has to be a number between 512 and 32767."},
221224
{[]string{"-h-4"}, "'-h -4': header value must be either -1 or a value between 1 and 2147483647"},
222225
{[]string{"-w", "6"}, "'-w 6': value must be greater than 8 and less than 65536."},
226+
{[]string{"--no-bom"}, "The --no-bom parameter requires -u (Unicode output file)."},
223227
}
224228

225229
for _, test := range commands {
@@ -298,6 +302,50 @@ func TestUnicodeOutput(t *testing.T) {
298302
}
299303
}
300304

305+
func TestUnicodeOutputNoBOM(t *testing.T) {
306+
o, err := os.CreateTemp("", "sqlcmdnobom")
307+
assert.NoError(t, err, "os.CreateTemp")
308+
defer os.Remove(o.Name())
309+
defer o.Close()
310+
args = newArguments()
311+
args.InputFile = []string{"testdata/selectutf8.txt"}
312+
args.OutputFile = o.Name()
313+
args.UnicodeOutputFile = true
314+
args.NoBOM = true
315+
setAzureAuthArgIfNeeded(&args)
316+
vars := sqlcmd.InitializeVariables(args.useEnvVars())
317+
setVars(vars, &args)
318+
319+
exitCode, err := run(vars, &args)
320+
assert.NoError(t, err, "run")
321+
assert.Equal(t, 0, exitCode, "exitCode")
322+
fileBytes, err := os.ReadFile(o.Name())
323+
if assert.NoError(t, err, "os.ReadFile") {
324+
// With --no-bom, the file should NOT start with FF FE (UTF-16 LE BOM)
325+
assert.True(t, len(fileBytes) >= 2, "output file should have content")
326+
hasBOM := len(fileBytes) >= 2 && fileBytes[0] == 0xFF && fileBytes[1] == 0xFE
327+
assert.False(t, hasBOM, "output file should NOT have BOM when --no-bom is used")
328+
329+
// Verify content is valid UTF-16 LE by decoding and checking for expected text
330+
// UTF-16 LE uses 2 bytes per character, so file size should be even
331+
assert.Equal(t, 0, len(fileBytes)%2, "UTF-16 LE output should have even number of bytes")
332+
// Decode first few bytes as UTF-16 LE and verify it contains recognizable content
333+
if len(fileBytes) >= 4 {
334+
// Check for ASCII-range characters encoded as UTF-16 LE (low byte first, high byte = 0)
335+
// Common characters like digits, letters have high byte = 0 in UTF-16 LE
336+
hasValidUtf16Pattern := false
337+
for i := 0; i+1 < len(fileBytes); i += 2 {
338+
// In UTF-16 LE, ASCII chars have low byte = char, high byte = 0
339+
if fileBytes[i+1] == 0 && fileBytes[i] >= 0x20 && fileBytes[i] < 0x7F {
340+
hasValidUtf16Pattern = true
341+
break
342+
}
343+
}
344+
assert.True(t, hasValidUtf16Pattern, "output should contain valid UTF-16 LE encoded content")
345+
}
346+
}
347+
}
348+
301349
func TestUnicodeInput(t *testing.T) {
302350
testfiles := []string{
303351
filepath.Join(`testdata`, `selectutf8.txt`),

pkg/sqlcmd/commands.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -321,9 +321,12 @@ func outCommand(s *Sqlcmd, args []string, line uint) error {
321321
return InvalidFileError(err, args[0])
322322
}
323323
if s.UnicodeOutputFile {
324-
// ODBC sqlcmd doesn't write a BOM but we will.
325-
// Maybe the endian-ness should be configurable.
326-
win16le := unicode.UTF16(unicode.LittleEndian, unicode.UseBOM)
324+
// By default we write a BOM, but --no-bom omits it for ODBC sqlcmd compatibility
325+
bomPolicy := unicode.UseBOM
326+
if s.NoBOM {
327+
bomPolicy = unicode.IgnoreBOM
328+
}
329+
win16le := unicode.UTF16(unicode.LittleEndian, bomPolicy)
327330
encoder := transform.NewWriter(o, win16le.NewEncoder())
328331
s.SetOutput(encoder)
329332
} else {

pkg/sqlcmd/sqlcmd.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ type Sqlcmd struct {
8484
PrintError func(msg string, severity uint8) bool
8585
// UnicodeOutputFile is true when UTF16 file output is needed
8686
UnicodeOutputFile bool
87+
// NoBOM omits the BOM from UTF-16 output files (ODBC sqlcmd compatibility)
88+
NoBOM bool
8789
// EchoInput tells the GO command to print the batch text before running the query
8890
EchoInput bool
8991
colorizer color.Colorizer

0 commit comments

Comments
 (0)