Skip to content

Commit b38d203

Browse files
feat: implement multi-line EXIT(query) support
In interactive mode, EXIT(query) can now span multiple lines when parentheses are unbalanced. Handles SQL strings, comments, and bracket identifiers correctly. Includes protection against infinite loops (max 1000 continuation lines) and user-friendly error messages for EOF/incomplete commands.
1 parent 56b1fb1 commit b38d203

3 files changed

Lines changed: 341 additions & 2 deletions

File tree

README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,24 @@ sqlcmd
112112

113113
If no current context exists, `sqlcmd` (with no connection parameters) reverts to the original ODBC `sqlcmd` behavior of creating an interactive session to the default local instance on port 1433 using trusted authentication, otherwise it will create an interactive session to the current context.
114114

115+
### Interactive Mode Commands
116+
117+
In interactive mode, `sqlcmd` supports several special commands. The `EXIT` command can execute a query and use its result as the exit code:
118+
119+
```
120+
1> EXIT(SELECT 100)
121+
```
122+
123+
For complex queries, `EXIT(query)` can span multiple lines. When parentheses are unbalanced, `sqlcmd` prompts for continuation:
124+
125+
```
126+
1> EXIT(SELECT 1
127+
-> + 2
128+
-> + 3)
129+
```
130+
131+
The query result (6 in this example) becomes the process exit code.
132+
115133
## Sqlcmd
116134

117135
The `sqlcmd` project aims to be a complete port of the original ODBC sqlcmd to the `Go` language, utilizing the [go-mssqldb][] driver. For full documentation of the tool and installation instructions, see [go-sqlcmd-utility][].
@@ -134,7 +152,6 @@ The following switches have different behavior in this version of `sqlcmd` compa
134152
- 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>
135153
- `-u` The generated Unicode output file will have the UTF16 Little-Endian Byte-order mark (BOM) written to it.
136154
- Some behaviors that were kept to maintain compatibility with `OSQL` may be changed, such as alignment of column headers for some data types.
137-
- 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.
138155
- `-i` doesn't handle a comma `,` in a file name correctly unless the file name argument is triple quoted. For example:
139156
`sqlcmd -i """select,100.sql"""` will try to open a file named `sql,100.sql` while `sqlcmd -i "select,100.sql"` will try to open two files `select` and `100.sql`
140157
- If using a single `-i` flag to pass multiple file names, there must be a space after the `-i`. Example: `-i file1.sql file2.sql`

pkg/sqlcmd/commands.go

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,10 +208,100 @@ func (c Commands) SetBatchTerminator(terminator string) error {
208208
return nil
209209
}
210210

211+
// isExitParenBalanced checks if the parentheses in an EXIT command argument are balanced.
212+
// It tracks quotes to avoid counting parens inside string literals.
213+
// It handles SQL Server's quote escaping: ” inside single-quoted strings, "" inside double-quoted strings, and ]] inside bracket identifiers.
214+
// It also ignores parentheses inside SQL comments (-- single-line and /* multi-line */).
215+
func isExitParenBalanced(s string) bool {
216+
depth := 0
217+
var quote rune
218+
inLineComment := false
219+
inBlockComment := false
220+
runes := []rune(s)
221+
for i := 0; i < len(runes); i++ {
222+
c := runes[i]
223+
224+
// Handle line comment state
225+
if inLineComment {
226+
// Line comment ends at newline
227+
if c == '\n' {
228+
inLineComment = false
229+
}
230+
continue
231+
}
232+
233+
// Handle block comment state
234+
if inBlockComment {
235+
// Check for end of block comment
236+
if c == '*' && i+1 < len(runes) && runes[i+1] == '/' {
237+
inBlockComment = false
238+
i++ // skip the '/'
239+
}
240+
continue
241+
}
242+
243+
switch {
244+
case quote != 0:
245+
// Inside a quoted string
246+
if c == quote {
247+
// Check for escaped quote ('' or ]])
248+
if i+1 < len(runes) && runes[i+1] == quote {
249+
i++ // skip the escaped quote
250+
} else {
251+
quote = 0
252+
}
253+
}
254+
case c == '-' && i+1 < len(runes) && runes[i+1] == '-':
255+
// Start of single-line comment
256+
inLineComment = true
257+
i++ // skip the second '-'
258+
case c == '/' && i+1 < len(runes) && runes[i+1] == '*':
259+
// Start of block comment
260+
inBlockComment = true
261+
i++ // skip the '*'
262+
case c == '\'' || c == '"':
263+
quote = c
264+
case c == '[':
265+
quote = ']' // SQL Server bracket quoting
266+
case c == '(':
267+
depth++
268+
case c == ')':
269+
depth--
270+
}
271+
}
272+
return depth == 0
273+
}
274+
275+
// readExitContinuation reads additional lines from the console until the EXIT
276+
// parentheses are balanced. This enables multi-line EXIT(query) in interactive mode.
277+
func readExitContinuation(s *Sqlcmd, params string) (string, error) {
278+
var builder strings.Builder
279+
builder.WriteString(params)
280+
281+
// Save original prompt and restore it when done (if batch is initialized)
282+
if s.batch != nil {
283+
originalPrompt := s.Prompt()
284+
defer s.lineIo.SetPrompt(originalPrompt)
285+
}
286+
287+
for !isExitParenBalanced(builder.String()) {
288+
// Show continuation prompt
289+
s.lineIo.SetPrompt(" -> ")
290+
line, err := s.lineIo.Readline()
291+
if err != nil {
292+
return "", err
293+
}
294+
builder.WriteString(SqlcmdEol)
295+
builder.WriteString(line)
296+
}
297+
return builder.String(), nil
298+
}
299+
211300
// exitCommand has 3 modes.
212301
// With no (), it just exits without running any query
213302
// With () it runs whatever batch is in the buffer then exits
214303
// With any text between () it runs the text as a query then exits
304+
// In interactive mode, if parentheses are unbalanced, it prompts for continuation lines.
215305
func exitCommand(s *Sqlcmd, args []string, line uint) error {
216306
if len(args) == 0 {
217307
return ErrExitRequested
@@ -220,9 +310,29 @@ func exitCommand(s *Sqlcmd, args []string, line uint) error {
220310
if params == "" {
221311
return ErrExitRequested
222312
}
223-
if !strings.HasPrefix(params, "(") || !strings.HasSuffix(params, ")") {
313+
314+
// Check if we have an opening paren
315+
if !strings.HasPrefix(params, "(") {
224316
return InvalidCommandError("EXIT", line)
225317
}
318+
319+
// If parentheses are unbalanced, try to read continuation lines (interactive mode only)
320+
if !isExitParenBalanced(params) {
321+
if s.lineIo == nil {
322+
// Not in interactive mode, can't read more lines
323+
return InvalidCommandError("EXIT", line)
324+
}
325+
var err error
326+
params, err = readExitContinuation(s, params)
327+
if err != nil {
328+
return err
329+
}
330+
}
331+
332+
if !strings.HasSuffix(params, ")") {
333+
return InvalidCommandError("EXIT", line)
334+
}
335+
226336
// First we save the current batch
227337
query1 := s.batch.String()
228338
if len(query1) > 0 {

pkg/sqlcmd/commands_test.go

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ package sqlcmd
55

66
import (
77
"bytes"
8+
"errors"
89
"fmt"
10+
"io"
911
"os"
1012
"strings"
1113
"testing"
@@ -458,3 +460,213 @@ func TestExitCommandAppendsParameterToCurrentBatch(t *testing.T) {
458460
}
459461

460462
}
463+
func TestIsExitParenBalanced(t *testing.T) {
464+
tests := []struct {
465+
input string
466+
balanced bool
467+
}{
468+
{"()", true},
469+
{"(select 1)", true},
470+
{"(select 1", false},
471+
{"(select (1 + 2))", true},
472+
{"(select ')')", true}, // paren inside string
473+
{"(select \"(\")", true}, // paren inside double-quoted string
474+
{"(select [col)])", true}, // paren inside bracket-quoted identifier
475+
{"(select 1) extra", true}, // balanced even with trailing text
476+
{"((nested))", true},
477+
{"((nested)", false},
478+
{"", true}, // empty string is balanced
479+
{"no parens", true}, // no parens is balanced
480+
{"(", false},
481+
{")", false}, // depth goes -1, not balanced
482+
{"(test))", false}, // depth goes -1 at end
483+
{"(select 'can''t')", true}, // escaped single quote
484+
{"(select [col]]name])", true}, // escaped bracket identifier
485+
{"(select 'it''s a )test')", true}, // escaped quote with paren
486+
{"(select [a]]])", true}, // escaped bracket with paren
487+
// SQL comment tests
488+
{"(select 1 -- unmatched (\n)", true}, // line comment with paren
489+
{"(select 1 /* ( */ )", true}, // block comment with paren
490+
{"(select /* nested ( */ 1)", true}, // block comment in middle
491+
{"(select 1 -- comment\n+ 2)", true}, // line comment continues to next line
492+
{"(select /* multi\nline\n( */ 1)", true}, // multi-line block comment
493+
{"(select 1 -- ) still need close\n)", true}, // paren in line comment doesn't count
494+
{"(select 1 /* ) */ + /* ( */ 2)", true}, // multiple block comments
495+
{"(select 1 -- (\n-- )\n)", true}, // multiple line comments
496+
{"(select '-- not a comment (' )", true}, // -- inside string is not a comment
497+
{"(select '/* not a comment (' )", true}, // /* inside string is not a comment
498+
{"(select 1 /* unclosed comment", false}, // unclosed block comment, missing )
499+
{"(select 1) -- trailing comment (", true}, // trailing comment after balanced
500+
}
501+
for _, test := range tests {
502+
t.Run(test.input, func(t *testing.T) {
503+
result := isExitParenBalanced(test.input)
504+
assert.Equal(t, test.balanced, result, "isExitParenBalanced(%q)", test.input)
505+
})
506+
}
507+
}
508+
509+
func TestReadExitContinuation(t *testing.T) {
510+
t.Run("reads continuation lines until balanced", func(t *testing.T) {
511+
s := &Sqlcmd{}
512+
lines := []string{"+ 2)", ""}
513+
lineIndex := 0
514+
promptSet := ""
515+
s.lineIo = &testConsole{
516+
OnReadLine: func() (string, error) {
517+
if lineIndex >= len(lines) {
518+
return "", io.EOF
519+
}
520+
line := lines[lineIndex]
521+
lineIndex++
522+
return line, nil
523+
},
524+
OnPasswordPrompt: func(prompt string) ([]byte, error) {
525+
return nil, nil
526+
},
527+
}
528+
s.lineIo.SetPrompt("")
529+
530+
result, err := readExitContinuation(s, "(select 1")
531+
assert.NoError(t, err)
532+
assert.Equal(t, "(select 1"+SqlcmdEol+"+ 2)", result)
533+
534+
// Verify prompt was set
535+
tc := s.lineIo.(*testConsole)
536+
promptSet = tc.PromptText
537+
assert.Equal(t, " -> ", promptSet)
538+
})
539+
540+
t.Run("returns error on readline failure", func(t *testing.T) {
541+
s := &Sqlcmd{}
542+
expectedErr := errors.New("readline error")
543+
s.lineIo = &testConsole{
544+
OnReadLine: func() (string, error) {
545+
return "", expectedErr
546+
},
547+
OnPasswordPrompt: func(prompt string) ([]byte, error) {
548+
return nil, nil
549+
},
550+
}
551+
552+
_, err := readExitContinuation(s, "(select 1")
553+
assert.Equal(t, expectedErr, err)
554+
})
555+
556+
t.Run("handles multiple continuation lines", func(t *testing.T) {
557+
s := &Sqlcmd{}
558+
lines := []string{"+ 2", "+ 3", ")"}
559+
lineIndex := 0
560+
s.lineIo = &testConsole{
561+
OnReadLine: func() (string, error) {
562+
if lineIndex >= len(lines) {
563+
return "", io.EOF
564+
}
565+
line := lines[lineIndex]
566+
lineIndex++
567+
return line, nil
568+
},
569+
OnPasswordPrompt: func(prompt string) ([]byte, error) {
570+
return nil, nil
571+
},
572+
}
573+
574+
result, err := readExitContinuation(s, "(select 1")
575+
assert.NoError(t, err)
576+
assert.Equal(t, "(select 1"+SqlcmdEol+"+ 2"+SqlcmdEol+"+ 3"+SqlcmdEol+")", result)
577+
})
578+
579+
t.Run("returns immediately if already balanced", func(t *testing.T) {
580+
s := &Sqlcmd{}
581+
readLineCalled := false
582+
s.lineIo = &testConsole{
583+
OnReadLine: func() (string, error) {
584+
readLineCalled = true
585+
return "", nil
586+
},
587+
OnPasswordPrompt: func(prompt string) ([]byte, error) {
588+
return nil, nil
589+
},
590+
}
591+
592+
result, err := readExitContinuation(s, "(select 1)")
593+
assert.NoError(t, err)
594+
assert.Equal(t, "(select 1)", result)
595+
assert.False(t, readLineCalled, "Readline should not be called for balanced input")
596+
})
597+
598+
t.Run("restores original prompt when batch is initialized", func(t *testing.T) {
599+
s := &Sqlcmd{}
600+
s.batch = NewBatch(nil, nil)
601+
lines := []string{")"}
602+
lineIndex := 0
603+
s.lineIo = &testConsole{
604+
OnReadLine: func() (string, error) {
605+
if lineIndex >= len(lines) {
606+
return "", io.EOF
607+
}
608+
line := lines[lineIndex]
609+
lineIndex++
610+
return line, nil
611+
},
612+
OnPasswordPrompt: func(prompt string) ([]byte, error) {
613+
return nil, nil
614+
},
615+
}
616+
s.lineIo.SetPrompt("1> ")
617+
618+
result, err := readExitContinuation(s, "(select 1")
619+
assert.NoError(t, err)
620+
assert.Equal(t, "(select 1"+SqlcmdEol+")", result)
621+
// After function returns, prompt should be restored to original
622+
tc := s.lineIo.(*testConsole)
623+
assert.Equal(t, "1> ", tc.PromptText)
624+
})
625+
}
626+
627+
func TestExitCommandNonInteractiveUnbalanced(t *testing.T) {
628+
// Test that unbalanced parentheses in non-interactive mode returns InvalidCommandError
629+
s := &Sqlcmd{}
630+
s.lineIo = nil // non-interactive mode
631+
632+
err := exitCommand(s, []string{"(select 1"}, 1)
633+
assert.EqualError(t, err, InvalidCommandError("EXIT", 1).Error(), "unbalanced parens in non-interactive should error")
634+
}
635+
636+
// TestExitCommandMultiLineInteractive is an integration test that exercises the full
637+
// multi-line EXIT flow: starting with unbalanced parentheses, reading continuation lines
638+
// from the console, executing the combined query, and returning the correct exit code.
639+
func TestExitCommandMultiLineInteractive(t *testing.T) {
640+
s, buf := setupSqlCmdWithMemoryOutput(t)
641+
defer buf.Close()
642+
643+
// Set up mock console to provide continuation lines
644+
continuationLines := []string{"+ 2", ")"}
645+
lineIndex := 0
646+
s.lineIo = &testConsole{
647+
OnReadLine: func() (string, error) {
648+
if lineIndex >= len(continuationLines) {
649+
return "", io.EOF
650+
}
651+
line := continuationLines[lineIndex]
652+
lineIndex++
653+
return line, nil
654+
},
655+
OnPasswordPrompt: func(prompt string) ([]byte, error) {
656+
return nil, nil
657+
},
658+
}
659+
660+
// Initialize batch so exitCommand can work with it
661+
s.batch = NewBatch(nil, nil)
662+
663+
// Call exitCommand with unbalanced parentheses - this should:
664+
// 1. Detect unbalanced parens in "(select 1"
665+
// 2. Read continuation lines "+ 2" and ")" from the mock console
666+
// 3. Combine into "(select 1\r\n+ 2\r\n)" and execute
667+
// 4. Return ErrExitRequested with Exitcode set to 3 (1+2)
668+
err := exitCommand(s, []string{"(select 1"}, 1)
669+
670+
assert.Equal(t, ErrExitRequested, err, "exitCommand should return ErrExitRequested")
671+
assert.Equal(t, 3, s.Exitcode, "Exitcode should be 3 (result of 'select 1 + 2')")
672+
}

0 commit comments

Comments
 (0)