Skip to content
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
in dialects without them.

### Fixed
- Watch now respects `--dialect`, re-validates changes and formats files in place
with the CLI safety checks; MCP parsing accepts a dialect and documents comment preservation.
- Correct transpile dialect help, avoid duplicate lint syntax errors, and rewrite
SQL Server SELECT TOP limits across dialects, refusing PERCENT and WITH TIES.
- Preserve dialect numeric and PostgreSQL Unicode-escape literals and quoted type names; correct JSON/power precedence and retain operator meaning during target-dialect formatting. Hand-built expression trees, including hand-built operands inserted into parsed trees, now render necessary parentheses.
- Formatting or transpiling to another dialect rewrites PostgreSQL `#` (XOR),
`~`, `<<`/`>>`, `&`/`|` and `||` (CONCAT in MySQL/MariaDB) into the target's
Expand Down
5 changes: 3 additions & 2 deletions cmd/gosqlx/cmd/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ func lintRun(cmd *cobra.Command, args []string) error {

totalViolations := errorCount + warningCount + infoCount
if syntaxErrorCount > 0 {
return fmt.Errorf("%d file(s) contain SQL syntax errors (and %d violation(s))", syntaxErrorCount, totalViolations)
return silentError{fmt.Errorf("%d file(s) contain SQL syntax errors (and %d violation(s))", syntaxErrorCount, totalViolations)}
}
if totalViolations > 0 {
return fmt.Errorf("found %d violation(s): %d error(s), %d warning(s), %d info(s)",
Expand Down Expand Up @@ -413,7 +413,8 @@ func lintOutcomeError(violations []linter.Violation, synErr error) error {
}

if synErr != nil {
return fmt.Errorf("invalid SQL: %w", synErr)
// The report already includes the syntax error; main only needs the failure status.
return silentError{fmt.Errorf("invalid SQL: %w", synErr)}
}
if errorCount > 0 || (lintFailOnWarn && warningCount > 0) {
return fmt.Errorf("%d error(s) and %d warning(s) found", errorCount, warningCount)
Expand Down
49 changes: 49 additions & 0 deletions cmd/gosqlx/cmd/lint_output_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright 2026 GoSQLX Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"strings"
"testing"
)

func TestLintSyntaxErrorReportedOnce(t *testing.T) {
bad := writeTemp(t, "invalid.sql", "SELECT FROM WHERE (\n")
for _, tc := range []struct {
name string
stdin *string
args []string
}{
{"inline", nil, []string{"lint", "SELECT FROM WHERE ("}},
{"stdin", str("SELECT FROM WHERE ("), []string{"lint"}},
{"file", nil, []string{"lint", bad}},
} {
t.Run(tc.name, func(t *testing.T) {
res := runCLI(t, tc.stdin, tc.args...)
if res.err == nil {
t.Fatal("invalid SQL must return an error for exit code 1")
}
if !IsSilentError(res.err) {
t.Fatalf("main would print an extra error after the lint report: %v", res.err)
}
if count := strings.Count(strings.ToLower(res.stdout), "syntax error:"); count != 1 {
t.Fatalf("expected one syntax error, got %d:\n%s", count, res.stdout)
}
if res.stderr != "" {
t.Fatalf("unexpected duplicate error output: %s", res.stderr)
}
})
}
}
11 changes: 7 additions & 4 deletions cmd/gosqlx/cmd/transpile.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,13 @@ var transpileCmd = &cobra.Command{
Short: "Convert SQL from one dialect to another",
Long: `Transpile SQL between dialects.

Supported dialect pairs:
mysql → postgres
postgres → mysql
postgres → sqlite
Source and target dialects:
mysql, postgresql (postgres), sqlite, sqlserver (mssql), oracle,
snowflake, clickhouse, mariadb

Any source/target combination is accepted. Dialect-specific rewrites cover a
subset of SQL features; other syntax is parsed and reformatted. Conversion
errors are returned for constructs known to be unsafe for the target dialect.

SQL can be provided as a positional argument or piped via stdin.

Expand Down
40 changes: 40 additions & 0 deletions cmd/gosqlx/cmd/transpile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright 2026 GoSQLX Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"strings"
"testing"
)

func TestTranspileHelpSupportedDialects(t *testing.T) {
res := runCLI(t, nil, "transpile", "--help")
if res.err != nil {
t.Fatal(res.err)
}
if strings.Contains(res.stdout, "Supported dialect pairs:") {
t.Fatalf("help still presents an incomplete pair list:\n%s", res.stdout)
}
for _, name := range []string{"mysql", "postgresql", "sqlite", "sqlserver", "oracle", "snowflake", "clickhouse", "mariadb"} {
t.Run(name, func(t *testing.T) {
if _, err := parseDialectFlag(name); err != nil {
t.Fatalf("documented dialect rejected: %v", err)
}
if !strings.Contains(res.stdout, name) {
t.Fatalf("help omits supported dialect %q:\n%s", name, res.stdout)
}
})
}
}
40 changes: 28 additions & 12 deletions cmd/gosqlx/cmd/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import (
"time"

"github.com/fsnotify/fsnotify"

"github.com/ajitpratap0/GoSQLX/cmd/gosqlx/internal/config"
)

// WatchMode represents the operation mode for file watching
Expand Down Expand Up @@ -62,6 +64,16 @@ type FileWatcher struct {

// NewFileWatcher creates a new file watcher instance
func NewFileWatcher(opts WatchOptions) (*FileWatcher, error) {
if opts.Mode == "" {
opts.Mode = WatchModeValidate
}
if opts.ValidatorOpts == nil {
opts.ValidatorOpts = &ValidatorOptions{}
}
if opts.FormatterOpts == nil {
defaults := FormatterOptionsFromConfig(config.DefaultConfig(), nil, FormatterFlags{})
opts.FormatterOpts = &defaults
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("failed to create file watcher: %w", err)
Expand All @@ -83,6 +95,7 @@ func (fw *FileWatcher) Watch(args []string) error {
defer cancel()

sigChan := make(chan os.Signal, 1)
defer signal.Stop(sigChan)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)

// Expand and add files/directories to watch
Expand Down Expand Up @@ -226,21 +239,24 @@ func (fw *FileWatcher) formatFile(filename string, timestamp string) {
return
}

formatter := NewFormatter(fw.opts.Out, fw.opts.Err, *fw.opts.FormatterOpts)
fileResult := formatter.formatFile(filename)

if fileResult.Error != nil {
fmt.Fprintf(fw.opts.Err, "[%s] %s %s - %v\n",
timestamp, colorRed("✗"), filename, fileResult.Error)
// Use the same formatting, verification and write path as format -i.
opts := *fw.opts.FormatterOpts
opts.InPlace = true
opts.Check = false
opts.Output = ""
formatter := NewFormatter(fw.opts.Out, fw.opts.Err, opts)
result, err := formatter.Format([]string{filename})
if err != nil {
fmt.Fprintf(fw.opts.Err, "[%s] %s %s - %v\n", timestamp, colorRed("✗"), filename, err)
return
}

if fileResult.Changed {
fmt.Fprintf(fw.opts.Out, "[%s] %s %s - Formatted\n",
timestamp, colorGreen("✓"), filename)
if result.FailedFiles > 0 {
return // Formatter.Format has already reported the error.
}
if result.FormattedFiles > 0 {
fmt.Fprintf(fw.opts.Out, "[%s] %s %s - Formatted\n", timestamp, colorGreen("✓"), filename)
} else {
fmt.Fprintf(fw.opts.Out, "[%s] %s %s - No changes needed\n",
timestamp, colorCyan("→"), filename)
fmt.Fprintf(fw.opts.Out, "[%s] %s %s - No changes needed\n", timestamp, colorCyan("→"), filename)
}
}

Expand Down
75 changes: 55 additions & 20 deletions cmd/gosqlx/cmd/watch_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"
)

Expand All @@ -38,34 +40,67 @@ Examples:
return cmd.Help()
}

modeStr, _ := cmd.Flags().GetString("mode")
debounce, _ := cmd.Flags().GetInt("debounce")
clear, _ := cmd.Flags().GetBool("clear")
watchVerbose, _ := cmd.Flags().GetBool("watch-verbose")

mode := WatchModeValidate
if modeStr == "format" {
mode = WatchModeFormat
}

opts := WatchOptions{
Mode: mode,
DebounceMs: debounce,
ClearScreen: clear,
Verbose: watchVerbose,
Out: cmd.OutOrStdout(),
Err: cmd.ErrOrStderr(),
}

fw, err := NewFileWatcher(opts)
fw, err := newWatcherFromCommand(cmd)
if err != nil {
return err
}
return fw.Watch(args)
},
}

// newWatcherFromCommand keeps command options separate from the blocking watch loop.
func newWatcherFromCommand(cmd *cobra.Command) (*FileWatcher, error) {
modeStr, err := cmd.Flags().GetString("mode")
if err != nil {
return nil, err
}
debounce, err := cmd.Flags().GetInt("debounce")
if err != nil {
return nil, err
}
clear, err := cmd.Flags().GetBool("clear")
if err != nil {
return nil, err
}
watchVerbose, err := cmd.Flags().GetBool("watch-verbose")
if err != nil {
return nil, err
}
dialect, err := cmd.Flags().GetString("dialect")
if err != nil {
return nil, err
}
if err := normalizeDialectFlag(&dialect); err != nil {
return nil, err
}

var mode WatchMode
switch modeStr {
case "validate":
mode = WatchModeValidate
case "format":
mode = WatchModeFormat
default:
return nil, fmt.Errorf("invalid --mode %q: want validate or format", modeStr)
}
fw, err := NewFileWatcher(WatchOptions{
Mode: mode,
DebounceMs: debounce,
ClearScreen: clear,
Verbose: watchVerbose,
Out: cmd.OutOrStdout(),
Err: cmd.ErrOrStderr(),
})
if err != nil {
return nil, err
}
fw.opts.ValidatorOpts.Dialect = dialect
fw.opts.FormatterOpts.Dialect = dialect
return fw, nil
}

func init() {
watchCmd.Flags().String("dialect", "", dialectFlagUsage("default: postgresql"))
watchCmd.Flags().String("mode", "validate", "watch mode: validate or format")
watchCmd.Flags().Int("debounce", 300, "debounce delay in milliseconds")
watchCmd.Flags().Bool("clear", false, "clear screen before each re-run")
Expand Down
Loading
Loading