diff --git a/README.md b/README.md index fe26e192..89f204cc 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,12 @@ client_interface_name go-mssqldb program_name sqlcmd ``` +- The `-q` (initial query) flag can now be combined with `-i` (input files). The initial query runs first, then the input files are processed. This is useful for setting session options before running scripts: + +```bash +sqlcmd -S server -q "SET PARSEONLY ON" -i script.sql +``` + - `sqlcmd` supports shared memory and named pipe transport. Use the appropriate protocol prefix on the server name to force a protocol: * `lpc` for shared memory, only for a localhost. `sqlcmd -S lpc:.` * `np` for named pipes. Or use the UNC named pipe path as the server name: `sqlcmd -S \\myserver\pipe\sql\query` diff --git a/cmd/sqlcmd/sqlcmd.go b/cmd/sqlcmd/sqlcmd.go index ea655b47..e55697ec 100644 --- a/cmd/sqlcmd/sqlcmd.go +++ b/cmd/sqlcmd/sqlcmd.go @@ -148,8 +148,8 @@ func (a *SQLCmdArguments) Validate(c *cobra.Command) (err error) { } if err == nil { switch { - case len(a.InputFile) > 0 && (len(a.Query) > 0 || len(a.InitialQuery) > 0): - err = mutuallyExclusiveError("i", `-Q/-q`) + case len(a.InputFile) > 0 && len(a.Query) > 0: + err = mutuallyExclusiveError("-i", "-Q") case a.UseTrustedConnection && (len(a.UserName) > 0 || len(a.Password) > 0): err = mutuallyExclusiveError("-E", `-U/-P`) case a.UseAad && len(a.AuthenticationMethod) > 0: @@ -400,7 +400,7 @@ func setFlags(rootCmd *cobra.Command, args *SQLCmdArguments) { rootCmd.Flags().BoolVarP(&args.Help, "help", "?", false, localizer.Sprintf("-? shows this syntax summary, %s shows modern sqlcmd sub-command help", localizer.HelpFlag)) rootCmd.Flags().StringVar(&args.TraceFile, "trace-file", "", localizer.Sprintf("Write runtime trace to the specified file. Only for advanced debugging.")) var inputfiles []string - rootCmd.Flags().StringSliceVarP(&args.InputFile, "input-file", "i", inputfiles, localizer.Sprintf("Identifies one or more files that contain batches of SQL statements. If one or more files do not exist, sqlcmd will exit. Mutually exclusive with %s/%s", localizer.QueryAndExitFlag, localizer.QueryFlag)) + rootCmd.Flags().StringSliceVarP(&args.InputFile, "input-file", "i", inputfiles, localizer.Sprintf("Identifies one or more files that contain batches of SQL statements. If one or more files do not exist, sqlcmd will exit. Mutually exclusive with %s. Can be combined with %s to run an initial query before the input files", localizer.QueryAndExitFlag, localizer.QueryFlag)) rootCmd.Flags().StringVarP(&args.OutputFile, "output-file", "o", "", localizer.Sprintf("Identifies the file that receives output from sqlcmd")) rootCmd.Flags().BoolVarP(&args.Version, "version", "", false, localizer.Sprintf("Print version information and exit")) rootCmd.Flags().BoolVarP(&args.TrustServerCertificate, "trust-server-certificate", "C", false, localizer.Sprintf("Implicitly trust the server certificate without validation")) @@ -890,14 +890,21 @@ func run(vars *sqlcmd.Variables, args *SQLCmdArguments) (int, error) { s.Query = args.Query } else if args.InitialQuery != "" { s.Query = args.InitialQuery + if !isInteractive { + once = true + } } - iactive := args.InputFile == nil && args.Query == "" - if iactive || s.Query != "" { + + // Run initial query (-q) if provided, even when combined with input files (-i) + if s.Query != "" { // If we're not in interactive mode and stdin is redirected, // we want to process all input without requiring GO statements processAll := !isInteractive err = s.Run(once, processAll) - } else { + } + + // Process input files after initial query (if any) + if err == nil && s.Exitcode == 0 && args.InputFile != nil { for f := range args.InputFile { if err = s.IncludeFile(args.InputFile[f], true); err != nil { s.WriteError(s.GetError(), err) @@ -905,6 +912,10 @@ func run(vars *sqlcmd.Variables, args *SQLCmdArguments) (int, error) { break } } + } else if args.InputFile == nil && args.Query == "" && args.InitialQuery == "" { + // Interactive mode: no query, no initial query, and no input files + processAll := !isInteractive + err = s.Run(once, processAll) } } s.SetOutput(nil) diff --git a/cmd/sqlcmd/sqlcmd_test.go b/cmd/sqlcmd/sqlcmd_test.go index 511816b2..5cf28c84 100644 --- a/cmd/sqlcmd/sqlcmd_test.go +++ b/cmd/sqlcmd/sqlcmd_test.go @@ -123,6 +123,10 @@ func TestValidCommandLineToArgsConversion(t *testing.T) { {[]string{"-N", "true", "-J", "/path/to/cert2.pem"}, func(args SQLCmdArguments) bool { return args.EncryptConnection == "true" && args.ServerCertificate == "/path/to/cert2.pem" }}, + // Test -q and -i can be used together (initial query runs before input files) + {[]string{"-q", "SET PARSEONLY ON", "-i", "script.sql"}, func(args SQLCmdArguments) bool { + return args.InitialQuery == "SET PARSEONLY ON" && len(args.InputFile) == 1 && args.InputFile[0] == "script.sql" + }}, } for _, test := range commands { @@ -165,7 +169,7 @@ func TestInvalidCommandLine(t *testing.T) { commands := []cmdLineTest{ {[]string{"-E", "-U", "someuser"}, "The -E and the -U/-P options are mutually exclusive."}, {[]string{"-L", "-q", `"select 1"`}, "The -L parameter can not be used in combination with other parameters."}, - {[]string{"-i", "foo.sql", "-q", `"select 1"`}, "The i and the -Q/-q options are mutually exclusive."}, + {[]string{"-i", "foo.sql", "-Q", `"select 1"`}, "The -i and the -Q options are mutually exclusive."}, {[]string{"-r", "5"}, "'-r 5': Unexpected argument. Argument value has to be one of [0 1]."}, {[]string{"-w", "x"}, "'-w x': value must be greater than 8 and less than 65536."}, {[]string{"-y", "111111"}, "'-y 111111': value must be greater than or equal to 0 and less than or equal to 8000."}, @@ -269,6 +273,33 @@ func TestRunInputFiles(t *testing.T) { } } +// TestInitialQueryWithInputFile verifies that -q (initial query) executes before -i (input files) +func TestInitialQueryWithInputFile(t *testing.T) { + o, err := os.CreateTemp("", "sqlcmdmain") + assert.NoError(t, err, "os.CreateTemp") + defer os.Remove(o.Name()) + defer o.Close() + args = newArguments() + // Use -q to change session language, then -i to verify the setting persists + // The initial query sets LANGUAGE to German, then the script selects @@LANGUAGE + args.InitialQuery = "SET LANGUAGE German" + args.InputFile = []string{"testdata/select_init_value.sql"} + args.OutputFile = o.Name() + setAzureAuthArgIfNeeded(&args) + vars := sqlcmd.InitializeVariables(args.useEnvVars()) + vars.Set(sqlcmd.SQLCMDMAXVARTYPEWIDTH, "0") + setVars(vars, &args) + + exitCode, err := run(vars, &args) + assert.NoError(t, err, "run") + assert.Equal(t, 0, exitCode, "exitCode") + bytes, err := os.ReadFile(o.Name()) + if assert.NoError(t, err, "os.ReadFile") { + // Verify that the language set in the initial query is reflected in the script output + assert.Contains(t, string(bytes), "Deutsch", "Initial query should execute before input file") + } +} + func TestUnicodeOutput(t *testing.T) { o, err := os.CreateTemp("", "sqlcmdmain") assert.NoError(t, err, "os.CreateTemp") diff --git a/cmd/sqlcmd/testdata/select_init_value.sql b/cmd/sqlcmd/testdata/select_init_value.sql new file mode 100644 index 00000000..169128e5 --- /dev/null +++ b/cmd/sqlcmd/testdata/select_init_value.sql @@ -0,0 +1 @@ +select @@LANGUAGE