Skip to content

Commit 42b44aa

Browse files
ajitpratap0Ajit Pratap Singhclaude
authored
feat(transpiler): SQL dialect transpilation API (#449)
Add pkg/transpiler/ package implementing a parse-rewrite-format pipeline for converting SQL between dialects. Rewrite rules mutate the AST in place and are composable per dialect pair. Supported pairs and rules: - MySQL → PostgreSQL: AUTO_INCREMENT→SERIAL/BIGSERIAL, TINYINT(1)→BOOLEAN - PostgreSQL → MySQL: SERIAL→INT AUTO_INCREMENT, ILIKE→LOWER() LIKE LOWER() - PostgreSQL → SQLite: SERIAL/BIGSERIAL→INTEGER, []array types→TEXT Also exposes gosqlx.Transpile() top-level wrapper and adds `gosqlx transpile --from <dialect> --to <dialect>` CLI subcommand. Co-authored-by: Ajit Pratap Singh <ajitpratapsingh@Ajits-Mac-mini-2655.local> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent dd92a2f commit 42b44aa

File tree

14 files changed

+886
-0
lines changed

14 files changed

+886
-0
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- **SQL Transpilation** (`pkg/transpiler`): New `Transpile(sql, from, to)` function converts SQL between dialects with a composable rewrite-rule pipeline
12+
- MySQL → PostgreSQL: `AUTO_INCREMENT``SERIAL`/`BIGSERIAL`, `TINYINT(1)``BOOLEAN`
13+
- PostgreSQL → MySQL: `SERIAL``INT AUTO_INCREMENT`, `ILIKE``LOWER() LIKE LOWER()`
14+
- PostgreSQL → SQLite: `SERIAL`/`BIGSERIAL``INTEGER`, array types → `TEXT`
15+
- `gosqlx.Transpile()` top-level convenience wrapper
16+
- `gosqlx transpile --from <dialect> --to <dialect>` CLI subcommand
1117
- **MariaDB dialect** (`--dialect mariadb`): New SQL dialect extending MySQL with support for SEQUENCE DDL (`CREATE/DROP/ALTER SEQUENCE` with full option set), temporal tables (`FOR SYSTEM_TIME`, `WITH SYSTEM VERSIONING`, `PERIOD FOR`), and `CONNECT BY` hierarchical queries with `PRIOR`, `START WITH`, and `NOCYCLE`
1218

1319
## [1.13.0] - 2026-03-20

cmd/gosqlx/cmd/transpile.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright 2026 GoSQLX Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package cmd
16+
17+
import (
18+
"fmt"
19+
"io"
20+
"os"
21+
"strings"
22+
23+
"github.com/spf13/cobra"
24+
25+
"github.com/ajitpratap0/GoSQLX/pkg/gosqlx"
26+
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
27+
)
28+
29+
var transpileCmd = &cobra.Command{
30+
Use: "transpile [SQL]",
31+
Short: "Convert SQL from one dialect to another",
32+
Long: `Transpile SQL between dialects.
33+
34+
Supported dialect pairs:
35+
mysql → postgres
36+
postgres → mysql
37+
postgres → sqlite
38+
39+
SQL can be provided as a positional argument or piped via stdin.
40+
41+
Examples:
42+
gosqlx transpile --from mysql --to postgres "CREATE TABLE t (id INT AUTO_INCREMENT PRIMARY KEY)"
43+
echo "SELECT * FROM users WHERE name ILIKE '%alice%'" | gosqlx transpile --from postgres --to mysql
44+
gosqlx transpile --from postgres --to sqlite "CREATE TABLE t (id SERIAL PRIMARY KEY, tags TEXT)"`,
45+
Args: cobra.MaximumNArgs(1),
46+
RunE: func(cmd *cobra.Command, args []string) error {
47+
fromStr, _ := cmd.Flags().GetString("from")
48+
toStr, _ := cmd.Flags().GetString("to")
49+
50+
from, err := parseDialectFlag(fromStr)
51+
if err != nil {
52+
return fmt.Errorf("--from: %w", err)
53+
}
54+
to, err := parseDialectFlag(toStr)
55+
if err != nil {
56+
return fmt.Errorf("--to: %w", err)
57+
}
58+
59+
var sql string
60+
if len(args) > 0 {
61+
sql = args[0]
62+
} else {
63+
// Read from stdin.
64+
data, readErr := io.ReadAll(os.Stdin)
65+
if readErr != nil {
66+
return fmt.Errorf("reading stdin: %w", readErr)
67+
}
68+
sql = strings.TrimSpace(string(data))
69+
}
70+
71+
if sql == "" {
72+
return fmt.Errorf("no SQL provided: pass as argument or via stdin")
73+
}
74+
75+
result, err := gosqlx.Transpile(sql, from, to)
76+
if err != nil {
77+
return fmt.Errorf("transpile: %w", err)
78+
}
79+
fmt.Println(result)
80+
return nil
81+
},
82+
}
83+
84+
func init() {
85+
transpileCmd.Flags().String("from", "mysql", "Source dialect (mysql, postgres, sqlite, sqlserver, oracle, snowflake, clickhouse, mariadb)")
86+
transpileCmd.Flags().String("to", "postgres", "Target dialect (mysql, postgres, sqlite, sqlserver, oracle, snowflake, clickhouse, mariadb)")
87+
rootCmd.AddCommand(transpileCmd)
88+
}
89+
90+
// parseDialectFlag converts a dialect name string to a keywords.SQLDialect value.
91+
func parseDialectFlag(s string) (keywords.SQLDialect, error) {
92+
switch strings.ToLower(s) {
93+
case "mysql":
94+
return keywords.DialectMySQL, nil
95+
case "postgres", "postgresql":
96+
return keywords.DialectPostgreSQL, nil
97+
case "sqlite":
98+
return keywords.DialectSQLite, nil
99+
case "sqlserver", "mssql":
100+
return keywords.DialectSQLServer, nil
101+
case "oracle":
102+
return keywords.DialectOracle, nil
103+
case "snowflake":
104+
return keywords.DialectSnowflake, nil
105+
case "clickhouse":
106+
return keywords.DialectClickHouse, nil
107+
case "mariadb":
108+
return keywords.DialectMariaDB, nil
109+
default:
110+
return keywords.DialectGeneric, fmt.Errorf(
111+
"unknown dialect %q; valid: mysql, postgres, sqlite, sqlserver, oracle, snowflake, clickhouse, mariadb", s)
112+
}
113+
}

pkg/gosqlx/gosqlx.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
2727
"github.com/ajitpratap0/GoSQLX/pkg/sql/parser"
2828
"github.com/ajitpratap0/GoSQLX/pkg/sql/tokenizer"
29+
"github.com/ajitpratap0/GoSQLX/pkg/transpiler"
2930
)
3031

3132
// Version is the current GoSQLX library version.
@@ -657,3 +658,25 @@ func Normalize(sql string) (string, error) {
657658
func Fingerprint(sql string) (string, error) {
658659
return fingerprint.Fingerprint(sql)
659660
}
661+
662+
// Transpile converts SQL from one dialect to another.
663+
//
664+
// Supported dialect pairs:
665+
// - MySQL → PostgreSQL (AUTO_INCREMENT→SERIAL, TINYINT(1)→BOOLEAN)
666+
// - PostgreSQL → MySQL (SERIAL→AUTO_INCREMENT, ILIKE→LOWER() LIKE LOWER())
667+
// - PostgreSQL → SQLite (SERIAL→INTEGER, array types→TEXT)
668+
//
669+
// For unsupported dialect pairs the SQL is parsed and reformatted without any
670+
// dialect-specific rewrites (passthrough with normalisation).
671+
//
672+
// Example:
673+
//
674+
// result, err := gosqlx.Transpile(
675+
// "CREATE TABLE t (id INT AUTO_INCREMENT PRIMARY KEY)",
676+
// keywords.DialectMySQL,
677+
// keywords.DialectPostgreSQL,
678+
// )
679+
// // result: "CREATE TABLE t (id SERIAL PRIMARY KEY)"
680+
func Transpile(sql string, from, to keywords.SQLDialect) (string, error) {
681+
return transpiler.Transpile(sql, from, to)
682+
}

pkg/gosqlx/transpile_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright 2026 GoSQLX Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package gosqlx_test
16+
17+
import (
18+
"strings"
19+
"testing"
20+
21+
"github.com/ajitpratap0/GoSQLX/pkg/gosqlx"
22+
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
23+
)
24+
25+
func TestGoSQLX_Transpile_BasicSelect(t *testing.T) {
26+
sql := "SELECT id, name FROM users WHERE id = 1"
27+
result, err := gosqlx.Transpile(sql, keywords.DialectMySQL, keywords.DialectPostgreSQL)
28+
if err != nil {
29+
t.Fatalf("Transpile: %v", err)
30+
}
31+
if result == "" {
32+
t.Error("expected non-empty result")
33+
}
34+
if !strings.Contains(strings.ToUpper(result), "SELECT") {
35+
t.Errorf("result should contain SELECT, got: %s", result)
36+
}
37+
}
38+
39+
func TestGoSQLX_Transpile_InvalidSQL(t *testing.T) {
40+
_, err := gosqlx.Transpile("NOT VALID", keywords.DialectMySQL, keywords.DialectPostgreSQL)
41+
if err == nil {
42+
t.Fatal("expected error for invalid SQL")
43+
}
44+
}
45+
46+
func TestGoSQLX_Transpile_AutoIncrementToSerial(t *testing.T) {
47+
sql := "CREATE TABLE users (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255))"
48+
result, err := gosqlx.Transpile(sql, keywords.DialectMySQL, keywords.DialectPostgreSQL)
49+
if err != nil {
50+
t.Fatalf("Transpile: %v", err)
51+
}
52+
if !strings.Contains(strings.ToUpper(result), "SERIAL") {
53+
t.Errorf("expected SERIAL in output, got: %s", result)
54+
}
55+
}

pkg/transpiler/dialect_rules.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright 2026 GoSQLX Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package transpiler
16+
17+
import (
18+
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
19+
"github.com/ajitpratap0/GoSQLX/pkg/transpiler/rules"
20+
)
21+
22+
type dialectPair struct {
23+
from, to keywords.SQLDialect
24+
}
25+
26+
var ruleRegistry = map[dialectPair][]RewriteRule{}
27+
28+
func init() {
29+
register(keywords.DialectMySQL, keywords.DialectPostgreSQL,
30+
rules.MySQLAutoIncrementToSerial,
31+
rules.MySQLBacktickToDoubleQuote,
32+
rules.MySQLLimitCommaToOffset,
33+
rules.MySQLBooleanToPgBool,
34+
)
35+
register(keywords.DialectPostgreSQL, keywords.DialectMySQL,
36+
rules.PgSerialToAutoIncrement,
37+
rules.PgDoubleQuoteToBacktick,
38+
rules.PgILikeToLower,
39+
)
40+
register(keywords.DialectPostgreSQL, keywords.DialectSQLite,
41+
rules.PgSerialToIntegerPK,
42+
rules.PgArrayToJSON,
43+
)
44+
}
45+
46+
func register(from, to keywords.SQLDialect, rs ...RewriteRule) {
47+
key := dialectPair{from, to}
48+
ruleRegistry[key] = append(ruleRegistry[key], rs...)
49+
}
50+
51+
// RulesFor returns the registered rewrite rules for a dialect pair.
52+
// Returns nil (empty) for unregistered or same-dialect pairs.
53+
// Exported for testing.
54+
func RulesFor(from, to keywords.SQLDialect) []RewriteRule {
55+
if from == to {
56+
return nil
57+
}
58+
return ruleRegistry[dialectPair{from, to}]
59+
}
60+
61+
// rulesFor is the internal version used by Transpile.
62+
func rulesFor(from, to keywords.SQLDialect) []RewriteRule {
63+
return RulesFor(from, to)
64+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright 2026 GoSQLX Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package transpiler_test
16+
17+
import (
18+
"testing"
19+
20+
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
21+
"github.com/ajitpratap0/GoSQLX/pkg/transpiler"
22+
)
23+
24+
func TestRulesFor_MySQLToPostgres_NonEmpty(t *testing.T) {
25+
rules := transpiler.RulesFor(keywords.DialectMySQL, keywords.DialectPostgreSQL)
26+
if len(rules) == 0 {
27+
t.Error("expected at least one rule for MySQL→PostgreSQL")
28+
}
29+
}
30+
31+
func TestRulesFor_SameDialect_Empty(t *testing.T) {
32+
rules := transpiler.RulesFor(keywords.DialectPostgreSQL, keywords.DialectPostgreSQL)
33+
if len(rules) != 0 {
34+
t.Errorf("expected no rules for same dialect, got %d", len(rules))
35+
}
36+
}
37+
38+
func TestRulesFor_UnregisteredPair_Empty(t *testing.T) {
39+
rules := transpiler.RulesFor(keywords.DialectOracle, keywords.DialectClickHouse)
40+
// Unknown pair → no rules (passthrough).
41+
_ = rules // should be 0 length, no panic
42+
}

0 commit comments

Comments
 (0)