sqlflipview 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlflipview-0.1.1/LICENSE +21 -0
- sqlflipview-0.1.1/PKG-INFO +80 -0
- sqlflipview-0.1.1/README.md +59 -0
- sqlflipview-0.1.1/pyproject.toml +33 -0
- sqlflipview-0.1.1/setup.cfg +4 -0
- sqlflipview-0.1.1/src/sqlflip/__init__.py +13 -0
- sqlflipview-0.1.1/src/sqlflip/__main__.py +4 -0
- sqlflipview-0.1.1/src/sqlflip/_utils.py +22 -0
- sqlflipview-0.1.1/src/sqlflip/cli.py +53 -0
- sqlflipview-0.1.1/src/sqlflip/converter.py +244 -0
- sqlflipview-0.1.1/src/sqlflip/detector.py +146 -0
- sqlflipview-0.1.1/src/sqlflip/gui.py +107 -0
- sqlflipview-0.1.1/src/sqlflipview.egg-info/PKG-INFO +80 -0
- sqlflipview-0.1.1/src/sqlflipview.egg-info/SOURCES.txt +16 -0
- sqlflipview-0.1.1/src/sqlflipview.egg-info/dependency_links.txt +1 -0
- sqlflipview-0.1.1/src/sqlflipview.egg-info/entry_points.txt +3 -0
- sqlflipview-0.1.1/src/sqlflipview.egg-info/top_level.txt +1 -0
- sqlflipview-0.1.1/tests/test_sqlflip.py +73 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jyoti
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlflipview
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Detect a SQL query's dialect and view it in PostgreSQL, MySQL, SQL Server and standard SQL.
|
|
5
|
+
Author-email: Jyoti <anuvishalp@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/anuvishalp/sqlflip-view
|
|
8
|
+
Project-URL: Source, https://github.com/anuvishalp/sqlflip-view
|
|
9
|
+
Project-URL: Issues, https://github.com/anuvishalp/sqlflip-view/issues
|
|
10
|
+
Keywords: sql,postgresql,mysql,tsql,sqlserver,sql-converter,sql-dialect
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Topic :: Database
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# sqlflip
|
|
23
|
+
|
|
24
|
+
Paste a SQL query and `sqlflip` will:
|
|
25
|
+
|
|
26
|
+
1. **Detect** which dialect it is written in - PostgreSQL, MySQL, SQL Server (T-SQL) or standard SQL.
|
|
27
|
+
2. **Convert** it to the other dialects (identifier quoting, `TOP` / `LIMIT` / `OFFSET .. FETCH`,
|
|
28
|
+
`GETDATE()` / `NOW()`, `ISNULL` / `IFNULL` / `COALESCE`, `LEN` / `LENGTH`, common data types).
|
|
29
|
+
3. **Display** all versions in a small window (Tkinter, no extra dependencies).
|
|
30
|
+
|
|
31
|
+
> T-SQL is the language of SQL Server, so `tsql` and `sqlserver` mean the same thing.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install sqlflip
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
(On Linux you may also need `sudo apt install python3-tk` for the window.)
|
|
40
|
+
|
|
41
|
+
## Use in Python
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from sqlflip import detect_dialect, convert, convert_all, display
|
|
45
|
+
|
|
46
|
+
q = "SELECT TOP 10 [name], ISNULL([age], 0) FROM [users] ORDER BY [name]"
|
|
47
|
+
|
|
48
|
+
print(detect_dialect(q)) # SQL Server (T-SQL) (confidence: high)
|
|
49
|
+
print(convert(q, "postgresql").sql) # SELECT "name", COALESCE("age", 0) FROM "users" ORDER BY "name" LIMIT 10
|
|
50
|
+
print(convert(q, "mysql").sql) # SELECT `name`, IFNULL(`age`, 0) FROM `users` ORDER BY `name` LIMIT 10
|
|
51
|
+
|
|
52
|
+
for dialect, result in convert_all(q).items():
|
|
53
|
+
print(dialect, "->", result.sql, result.notes)
|
|
54
|
+
|
|
55
|
+
display(q) # opens the window
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Command line
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
sqlflip "SELECT TOP 5 * FROM users"
|
|
62
|
+
sqlflip "SELECT * FROM users LIMIT 5, 10" --to sqlserver
|
|
63
|
+
sqlflip --gui
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Limitations
|
|
67
|
+
|
|
68
|
+
This is a lightweight, rule-based tool - **not** a full SQL parser. Always review the output.
|
|
69
|
+
Constructs it cannot safely translate (e.g. `::` casts, `||` / `+` string concatenation,
|
|
70
|
+
auto-increment columns, `GROUP_CONCAT` / `STRING_AGG`, date functions, variables) are left as-is
|
|
71
|
+
and reported in `result.notes`. Detection is heuristic: a query such as `SELECT * FROM t LIMIT 5`
|
|
72
|
+
is valid in both PostgreSQL and MySQL, so it is reported as ambiguous.
|
|
73
|
+
|
|
74
|
+
## Publish to PyPI
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install build twine
|
|
78
|
+
python -m build
|
|
79
|
+
twine upload dist/* # username: __token__ password: <your API token>
|
|
80
|
+
```
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# sqlflip
|
|
2
|
+
|
|
3
|
+
Paste a SQL query and `sqlflip` will:
|
|
4
|
+
|
|
5
|
+
1. **Detect** which dialect it is written in - PostgreSQL, MySQL, SQL Server (T-SQL) or standard SQL.
|
|
6
|
+
2. **Convert** it to the other dialects (identifier quoting, `TOP` / `LIMIT` / `OFFSET .. FETCH`,
|
|
7
|
+
`GETDATE()` / `NOW()`, `ISNULL` / `IFNULL` / `COALESCE`, `LEN` / `LENGTH`, common data types).
|
|
8
|
+
3. **Display** all versions in a small window (Tkinter, no extra dependencies).
|
|
9
|
+
|
|
10
|
+
> T-SQL is the language of SQL Server, so `tsql` and `sqlserver` mean the same thing.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install sqlflip
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
(On Linux you may also need `sudo apt install python3-tk` for the window.)
|
|
19
|
+
|
|
20
|
+
## Use in Python
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from sqlflip import detect_dialect, convert, convert_all, display
|
|
24
|
+
|
|
25
|
+
q = "SELECT TOP 10 [name], ISNULL([age], 0) FROM [users] ORDER BY [name]"
|
|
26
|
+
|
|
27
|
+
print(detect_dialect(q)) # SQL Server (T-SQL) (confidence: high)
|
|
28
|
+
print(convert(q, "postgresql").sql) # SELECT "name", COALESCE("age", 0) FROM "users" ORDER BY "name" LIMIT 10
|
|
29
|
+
print(convert(q, "mysql").sql) # SELECT `name`, IFNULL(`age`, 0) FROM `users` ORDER BY `name` LIMIT 10
|
|
30
|
+
|
|
31
|
+
for dialect, result in convert_all(q).items():
|
|
32
|
+
print(dialect, "->", result.sql, result.notes)
|
|
33
|
+
|
|
34
|
+
display(q) # opens the window
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Command line
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
sqlflip "SELECT TOP 5 * FROM users"
|
|
41
|
+
sqlflip "SELECT * FROM users LIMIT 5, 10" --to sqlserver
|
|
42
|
+
sqlflip --gui
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Limitations
|
|
46
|
+
|
|
47
|
+
This is a lightweight, rule-based tool - **not** a full SQL parser. Always review the output.
|
|
48
|
+
Constructs it cannot safely translate (e.g. `::` casts, `||` / `+` string concatenation,
|
|
49
|
+
auto-increment columns, `GROUP_CONCAT` / `STRING_AGG`, date functions, variables) are left as-is
|
|
50
|
+
and reported in `result.notes`. Detection is heuristic: a query such as `SELECT * FROM t LIMIT 5`
|
|
51
|
+
is valid in both PostgreSQL and MySQL, so it is reported as ambiguous.
|
|
52
|
+
|
|
53
|
+
## Publish to PyPI
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install build twine
|
|
57
|
+
python -m build
|
|
58
|
+
twine upload dist/* # username: __token__ password: <your API token>
|
|
59
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sqlflipview"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Detect a SQL query's dialect and view it in PostgreSQL, MySQL, SQL Server and standard SQL."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
authors = [{name = "Jyoti", email = "anuvishalp@gmail.com"}]
|
|
12
|
+
requires-python = ">=3.8"
|
|
13
|
+
keywords = ["sql", "postgresql", "mysql", "tsql", "sqlserver", "sql-converter", "sql-dialect"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Topic :: Database",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/anuvishalp/sqlflip-view"
|
|
25
|
+
Source = "https://github.com/anuvishalp/sqlflip-view"
|
|
26
|
+
Issues = "https://github.com/anuvishalp/sqlflip-view/issues"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
sqlflip = "sqlflip.cli:main"
|
|
30
|
+
sqlflip-gui = "sqlflip.gui:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""sqlflip - detect a SQL query's dialect and view it in other dialects."""
|
|
2
|
+
from .converter import Conversion, convert, convert_all
|
|
3
|
+
from .detector import DIALECTS, DISPLAY_NAMES, Detection, detect_dialect
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
__all__ = ["detect_dialect", "convert", "convert_all", "display",
|
|
7
|
+
"Detection", "Conversion", "DIALECTS", "DISPLAY_NAMES"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def display(query: str = "") -> None:
|
|
11
|
+
"""Open a window showing `query` in PostgreSQL, MySQL, SQL Server and standard SQL."""
|
|
12
|
+
from .gui import show # imported lazily so the library works without Tkinter
|
|
13
|
+
show(query)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Small helpers shared by the detector and the converter."""
|
|
2
|
+
import re
|
|
3
|
+
from typing import List, Tuple
|
|
4
|
+
|
|
5
|
+
_STRING_RE = re.compile(r"'(?:[^']|'')*'")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def mask_strings(sql: str) -> Tuple[str, List[str]]:
|
|
9
|
+
"""Replace every 'string literal' with a placeholder so regex rules
|
|
10
|
+
never touch text that lives inside quotes."""
|
|
11
|
+
strings: List[str] = []
|
|
12
|
+
|
|
13
|
+
def _sub(match):
|
|
14
|
+
strings.append(match.group(0))
|
|
15
|
+
return "\x00%d\x00" % (len(strings) - 1)
|
|
16
|
+
|
|
17
|
+
return _STRING_RE.sub(_sub, sql), strings
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def unmask_strings(sql: str, strings: List[str]) -> str:
|
|
21
|
+
"""Put the original string literals back."""
|
|
22
|
+
return re.sub(r"\x00(\d+)\x00", lambda m: strings[int(m.group(1))], sql)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Command-line interface: sqlflip "SELECT TOP 5 * FROM users" """
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .converter import convert, convert_all
|
|
6
|
+
from .detector import DIALECTS, DISPLAY_NAMES, detect_dialect, normalize_dialect
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main(argv=None) -> int:
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
prog="sqlflip", description="Detect a query's SQL dialect and convert it.")
|
|
12
|
+
parser.add_argument("query", nargs="?", help="SQL text (or use --file / pipe via stdin)")
|
|
13
|
+
parser.add_argument("-f", "--file", help="read the query from a file")
|
|
14
|
+
parser.add_argument("--from", dest="source", help="source dialect (default: auto-detect)")
|
|
15
|
+
parser.add_argument("--to", dest="target", help="show only this target dialect")
|
|
16
|
+
parser.add_argument("--gui", action="store_true", help="open the display window")
|
|
17
|
+
args = parser.parse_args(argv)
|
|
18
|
+
|
|
19
|
+
query = args.query
|
|
20
|
+
if args.file:
|
|
21
|
+
with open(args.file, encoding="utf-8") as fh:
|
|
22
|
+
query = fh.read()
|
|
23
|
+
elif query is None and not sys.stdin.isatty():
|
|
24
|
+
query = sys.stdin.read()
|
|
25
|
+
|
|
26
|
+
if args.gui or not query:
|
|
27
|
+
from .gui import show
|
|
28
|
+
show(query or "")
|
|
29
|
+
return 0
|
|
30
|
+
|
|
31
|
+
detection = detect_dialect(query)
|
|
32
|
+
source = normalize_dialect(args.source) if args.source else detection.dialect
|
|
33
|
+
print("Detected : %s" % detection)
|
|
34
|
+
if args.source:
|
|
35
|
+
print("Using : %s" % DISPLAY_NAMES[source])
|
|
36
|
+
print()
|
|
37
|
+
|
|
38
|
+
if args.target:
|
|
39
|
+
results = {normalize_dialect(args.target): convert(query, args.target, source)}
|
|
40
|
+
else:
|
|
41
|
+
results = convert_all(query, source)
|
|
42
|
+
|
|
43
|
+
for d, res in results.items():
|
|
44
|
+
print("--- %s %s---" % (DISPLAY_NAMES[d], "(source) " if d == source else ""))
|
|
45
|
+
print(res.sql)
|
|
46
|
+
for note in res.notes:
|
|
47
|
+
print(" ! " + note)
|
|
48
|
+
print()
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
sys.exit(main())
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Convert a SQL query from one dialect to another.
|
|
2
|
+
|
|
3
|
+
This is a lightweight, rule-based converter. It handles the most common
|
|
4
|
+
differences (identifier quoting, row limiting, a handful of functions and data
|
|
5
|
+
types) and *warns* about constructs it cannot translate safely.
|
|
6
|
+
"""
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from ._utils import mask_strings, unmask_strings
|
|
12
|
+
from .detector import DIALECTS, detect_dialect, normalize_dialect
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Conversion:
|
|
17
|
+
dialect: str
|
|
18
|
+
sql: str
|
|
19
|
+
notes: List[str] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --------------------------------------------------------------------------
|
|
23
|
+
# 1. Identifier quoting: [name] `name` "name"
|
|
24
|
+
# --------------------------------------------------------------------------
|
|
25
|
+
_QUOTE = {
|
|
26
|
+
"postgresql": ('"', '"'),
|
|
27
|
+
"standard": ('"', '"'),
|
|
28
|
+
"mysql": ("`", "`"),
|
|
29
|
+
"sqlserver": ("[", "]"),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _requote(text: str, source: str, target: str) -> str:
|
|
34
|
+
opener, closer = _QUOTE[target]
|
|
35
|
+
if source == "sqlserver":
|
|
36
|
+
patterns = [r"\[([^\]\x00]+)\]", r'"([^"\x00]+)"']
|
|
37
|
+
elif source == "mysql":
|
|
38
|
+
patterns = [r"`([^`]+)`"]
|
|
39
|
+
else:
|
|
40
|
+
patterns = [r'"([^"\x00]+)"']
|
|
41
|
+
for pattern in patterns:
|
|
42
|
+
text = re.sub(pattern, lambda m: "%s%s%s" % (opener, m.group(1), closer), text)
|
|
43
|
+
return text
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --------------------------------------------------------------------------
|
|
47
|
+
# 2. Row limiting: TOP n / LIMIT n [OFFSET m] / OFFSET m ROWS FETCH ...
|
|
48
|
+
# --------------------------------------------------------------------------
|
|
49
|
+
_TOP = re.compile(r"\bSELECT(\s+DISTINCT)?\s+TOP\s*\(?\s*(\d+)\s*\)?\s+", re.I)
|
|
50
|
+
_LIMIT_COMMA = re.compile(r"\s*\bLIMIT\s+(\d+)\s*,\s*(\d+)", re.I)
|
|
51
|
+
_LIMIT = re.compile(r"\s*\bLIMIT\s+(\d+)(?:\s+OFFSET\s+(\d+))?", re.I)
|
|
52
|
+
_OFFSET_FETCH = re.compile(
|
|
53
|
+
r"\s*\bOFFSET\s+(\d+)\s+ROWS?(?:\s+FETCH\s+(?:FIRST|NEXT)\s+(\d+)\s+ROWS?\s+ONLY)?", re.I
|
|
54
|
+
)
|
|
55
|
+
_FETCH_ONLY = re.compile(r"\s*\bFETCH\s+(?:FIRST|NEXT)\s+(\d+)\s+ROWS?\s+ONLY", re.I)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _cut(text: str, match) -> str:
|
|
59
|
+
return text[: match.start()] + text[match.end():]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _extract_limit(text: str) -> Tuple[str, Optional[int], Optional[int]]:
|
|
63
|
+
"""Remove any row-limit clause and return (text, limit, offset)."""
|
|
64
|
+
limit = offset = None
|
|
65
|
+
|
|
66
|
+
m = _TOP.search(text)
|
|
67
|
+
if m:
|
|
68
|
+
limit = int(m.group(2))
|
|
69
|
+
text = text[: m.start()] + "SELECT" + (m.group(1) or "") + " " + text[m.end():]
|
|
70
|
+
return text, limit, offset
|
|
71
|
+
|
|
72
|
+
m = _LIMIT_COMMA.search(text) # MySQL: LIMIT offset, count
|
|
73
|
+
if m:
|
|
74
|
+
return _cut(text, m), int(m.group(2)), int(m.group(1))
|
|
75
|
+
|
|
76
|
+
m = _LIMIT.search(text) # LIMIT n [OFFSET m]
|
|
77
|
+
if m:
|
|
78
|
+
off = int(m.group(2)) if m.group(2) else None
|
|
79
|
+
return _cut(text, m), int(m.group(1)), off
|
|
80
|
+
|
|
81
|
+
m = _OFFSET_FETCH.search(text) # OFFSET m ROWS [FETCH NEXT n ROWS ONLY]
|
|
82
|
+
if m:
|
|
83
|
+
lim = int(m.group(2)) if m.group(2) else None
|
|
84
|
+
return _cut(text, m), lim, int(m.group(1))
|
|
85
|
+
|
|
86
|
+
m = _FETCH_ONLY.search(text) # FETCH FIRST n ROWS ONLY
|
|
87
|
+
if m:
|
|
88
|
+
return _cut(text, m), int(m.group(1)), None
|
|
89
|
+
|
|
90
|
+
return text, None, None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _apply_limit(text: str, limit: Optional[int], offset: Optional[int], target: str) -> str:
|
|
94
|
+
if limit is None and not offset:
|
|
95
|
+
return text
|
|
96
|
+
|
|
97
|
+
if target in ("postgresql", "mysql"):
|
|
98
|
+
if target == "mysql" and limit is None:
|
|
99
|
+
limit = 18446744073709551615 # MySQL needs a LIMIT to use OFFSET
|
|
100
|
+
clause = ""
|
|
101
|
+
if limit is not None:
|
|
102
|
+
clause += " LIMIT %d" % limit
|
|
103
|
+
if offset:
|
|
104
|
+
clause += " OFFSET %d" % offset
|
|
105
|
+
return text + clause
|
|
106
|
+
|
|
107
|
+
if target == "standard":
|
|
108
|
+
clause = ""
|
|
109
|
+
if offset:
|
|
110
|
+
clause += " OFFSET %d ROWS" % offset
|
|
111
|
+
if limit is not None:
|
|
112
|
+
clause += " FETCH NEXT %d ROWS ONLY" % limit
|
|
113
|
+
return text + clause
|
|
114
|
+
|
|
115
|
+
# SQL Server
|
|
116
|
+
if not offset:
|
|
117
|
+
return re.sub(
|
|
118
|
+
r"^(\s*SELECT(?:\s+DISTINCT)?)\s+",
|
|
119
|
+
lambda m: "%s TOP %d " % (m.group(1), limit),
|
|
120
|
+
text, count=1, flags=re.I,
|
|
121
|
+
)
|
|
122
|
+
if not re.search(r"\bORDER\s+BY\b", text, re.I):
|
|
123
|
+
text += " ORDER BY (SELECT NULL)" # OFFSET/FETCH requires ORDER BY
|
|
124
|
+
text += " OFFSET %d ROWS" % offset
|
|
125
|
+
if limit is not None:
|
|
126
|
+
text += " FETCH NEXT %d ROWS ONLY" % limit
|
|
127
|
+
return text
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --------------------------------------------------------------------------
|
|
131
|
+
# 3. Functions
|
|
132
|
+
# --------------------------------------------------------------------------
|
|
133
|
+
_NOW = {"sqlserver": "GETDATE()", "mysql": "NOW()", "postgresql": "NOW()",
|
|
134
|
+
"standard": "CURRENT_TIMESTAMP"}
|
|
135
|
+
_NULLFN = {"sqlserver": "ISNULL", "mysql": "IFNULL", "postgresql": "COALESCE",
|
|
136
|
+
"standard": "COALESCE"}
|
|
137
|
+
_LENFN = {"sqlserver": "LEN", "mysql": "CHAR_LENGTH", "postgresql": "LENGTH",
|
|
138
|
+
"standard": "CHAR_LENGTH"}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _convert_functions(text: str, source: str, target: str) -> str:
|
|
142
|
+
text = re.sub(r"\b(?:GETDATE|SYSDATETIME|NOW|CURRENT_TIMESTAMP)\s*\(\s*\)",
|
|
143
|
+
_NOW[target], text, flags=re.I)
|
|
144
|
+
|
|
145
|
+
null_names = "IFNULL|ISNULL" if source == "sqlserver" else "IFNULL"
|
|
146
|
+
text = re.sub(r"\b(?:%s)\s*\(" % null_names, _NULLFN[target] + "(", text, flags=re.I)
|
|
147
|
+
|
|
148
|
+
text = re.sub(r"\b(?:LEN|LENGTH|CHAR_LENGTH|CHARACTER_LENGTH)\s*\(",
|
|
149
|
+
_LENFN[target] + "(", text, flags=re.I)
|
|
150
|
+
return text
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --------------------------------------------------------------------------
|
|
154
|
+
# 4. Data types (only for CREATE / ALTER statements)
|
|
155
|
+
# --------------------------------------------------------------------------
|
|
156
|
+
_TYPES = {
|
|
157
|
+
"sqlserver": [(r"\bBOOLEAN\b", "BIT"), (r"\bTIMESTAMP\b", "DATETIME2"),
|
|
158
|
+
(r"\bTEXT\b", "NVARCHAR(MAX)")],
|
|
159
|
+
"mysql": [(r"\bN?VARCHAR\s*\(\s*MAX\s*\)", "TEXT"), (r"\bNVARCHAR\b", "VARCHAR"),
|
|
160
|
+
(r"\bDATETIME2\b", "DATETIME"), (r"\bBIT\b", "BOOLEAN")],
|
|
161
|
+
"postgresql": [(r"\bN?VARCHAR\s*\(\s*MAX\s*\)", "TEXT"), (r"\bNVARCHAR\b", "VARCHAR"),
|
|
162
|
+
(r"\b(?:DATETIME2|DATETIME)\b", "TIMESTAMP"), (r"\bBIT\b", "BOOLEAN"),
|
|
163
|
+
(r"\bTINYINT\b", "SMALLINT")],
|
|
164
|
+
}
|
|
165
|
+
_TYPES["standard"] = _TYPES["postgresql"]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _convert_types(text: str, target: str) -> str:
|
|
169
|
+
if not re.match(r"\s*(CREATE|ALTER)\b", text, re.I):
|
|
170
|
+
return text
|
|
171
|
+
for pattern, repl in _TYPES[target]:
|
|
172
|
+
text = re.sub(pattern, repl, text, flags=re.I)
|
|
173
|
+
return text
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --------------------------------------------------------------------------
|
|
177
|
+
# 5. Warnings for things we do NOT translate
|
|
178
|
+
# --------------------------------------------------------------------------
|
|
179
|
+
def _collect_notes(masked: str, source: str, target: str) -> List[str]:
|
|
180
|
+
notes: List[str] = []
|
|
181
|
+
|
|
182
|
+
def has(pattern: str) -> bool:
|
|
183
|
+
return re.search(pattern, masked, re.I) is not None
|
|
184
|
+
|
|
185
|
+
if has(r"::\s*\w+") and target != "postgresql":
|
|
186
|
+
notes.append("PostgreSQL '::' casts were not converted - rewrite as CAST(expr AS type).")
|
|
187
|
+
if has(r"\|\|") and target in ("mysql", "sqlserver"):
|
|
188
|
+
notes.append("String concatenation with '||' was not converted - use CONCAT() "
|
|
189
|
+
"(MySQL / SQL Server) or '+' (SQL Server).")
|
|
190
|
+
if source == "sqlserver" and target != "sqlserver" and has(r"\x00\d+\x00\s*\+|\+\s*\x00\d+\x00"):
|
|
191
|
+
notes.append("String concatenation with '+' was not converted - use CONCAT() or '||'.")
|
|
192
|
+
if has(r"\b(AUTO_INCREMENT|IDENTITY|SERIAL|BIGSERIAL)\b"):
|
|
193
|
+
notes.append("Auto-increment columns differ (AUTO_INCREMENT / IDENTITY / SERIAL) - review manually.")
|
|
194
|
+
if has(r"\b(GROUP_CONCAT|STRING_AGG)\s*\("):
|
|
195
|
+
notes.append("GROUP_CONCAT / STRING_AGG are not converted - review manually.")
|
|
196
|
+
if has(r"\b(DATEADD|DATE_ADD|DATEDIFF|DATE_FORMAT|TO_CHAR|CONVERT|INTERVAL)\b"):
|
|
197
|
+
notes.append("Date/time functions differ a lot between dialects - review manually.")
|
|
198
|
+
if has(r"(?<![\w@])@\w+|\$\d+"):
|
|
199
|
+
notes.append("Variables / parameters (@name, $1) were not converted.")
|
|
200
|
+
return notes
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# --------------------------------------------------------------------------
|
|
204
|
+
# Public API
|
|
205
|
+
# --------------------------------------------------------------------------
|
|
206
|
+
def convert(query: str, target: str, source: Optional[str] = None) -> Conversion:
|
|
207
|
+
"""Convert `query` to the `target` dialect.
|
|
208
|
+
|
|
209
|
+
If `source` is omitted the dialect is auto-detected.
|
|
210
|
+
"""
|
|
211
|
+
target = normalize_dialect(target)
|
|
212
|
+
body = (query or "").strip()
|
|
213
|
+
if not body:
|
|
214
|
+
raise ValueError("The query is empty.")
|
|
215
|
+
src = normalize_dialect(source) if source else detect_dialect(body).dialect
|
|
216
|
+
|
|
217
|
+
if src == target:
|
|
218
|
+
return Conversion(target, body, [])
|
|
219
|
+
|
|
220
|
+
masked, strings = mask_strings(body)
|
|
221
|
+
notes = _collect_notes(masked, src, target)
|
|
222
|
+
|
|
223
|
+
text = masked.rstrip()
|
|
224
|
+
had_semicolon = text.endswith(";")
|
|
225
|
+
text = text.rstrip(";").rstrip()
|
|
226
|
+
|
|
227
|
+
text = _requote(text, src, target)
|
|
228
|
+
text, limit, offset = _extract_limit(text)
|
|
229
|
+
text = _apply_limit(text, limit, offset, target)
|
|
230
|
+
text = _convert_functions(text, src, target)
|
|
231
|
+
text = _convert_types(text, target)
|
|
232
|
+
|
|
233
|
+
if had_semicolon:
|
|
234
|
+
text += ";"
|
|
235
|
+
return Conversion(target, unmask_strings(text, strings), notes)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def convert_all(query: str, source: Optional[str] = None) -> Dict[str, Conversion]:
|
|
239
|
+
"""Return the query in every supported dialect."""
|
|
240
|
+
body = (query or "").strip()
|
|
241
|
+
if not body:
|
|
242
|
+
raise ValueError("The query is empty.")
|
|
243
|
+
src = normalize_dialect(source) if source else detect_dialect(body).dialect
|
|
244
|
+
return {d: convert(body, d, src) for d in DIALECTS}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Detect which SQL dialect a query is written in."""
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Dict, List, Tuple
|
|
5
|
+
|
|
6
|
+
from ._utils import mask_strings
|
|
7
|
+
|
|
8
|
+
# Order matters: it is also the tie-break order and the tab order in the GUI.
|
|
9
|
+
DIALECTS = ("postgresql", "mysql", "sqlserver", "standard")
|
|
10
|
+
|
|
11
|
+
DISPLAY_NAMES = {
|
|
12
|
+
"postgresql": "PostgreSQL",
|
|
13
|
+
"mysql": "MySQL",
|
|
14
|
+
"sqlserver": "SQL Server (T-SQL)",
|
|
15
|
+
"standard": "Standard SQL",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# T-SQL *is* the language of SQL Server, so both names map to "sqlserver".
|
|
19
|
+
_ALIASES = {
|
|
20
|
+
"postgresql": "postgresql", "postgres": "postgresql", "pg": "postgresql",
|
|
21
|
+
"psql": "postgresql", "plpgsql": "postgresql",
|
|
22
|
+
"mysql": "mysql", "mariadb": "mysql",
|
|
23
|
+
"sqlserver": "sqlserver", "sql server": "sqlserver", "sql_server": "sqlserver",
|
|
24
|
+
"tsql": "sqlserver", "t-sql": "sqlserver", "mssql": "sqlserver",
|
|
25
|
+
"standard": "standard", "sql": "standard", "ansi": "standard",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize_dialect(name: str) -> str:
|
|
30
|
+
key = (name or "").strip().lower()
|
|
31
|
+
if key not in _ALIASES:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
"Unknown dialect %r. Use one of: postgresql, mysql, sqlserver "
|
|
34
|
+
"(or tsql), standard (or sql)." % name
|
|
35
|
+
)
|
|
36
|
+
return _ALIASES[key]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# (dialect, regex, weight, human-readable label)
|
|
40
|
+
_RULES: List[Tuple[str, str, int, str]] = [
|
|
41
|
+
# ---- SQL Server / T-SQL ------------------------------------------------
|
|
42
|
+
("sqlserver", r"\bSELECT\s+(DISTINCT\s+)?TOP\s*\(?\s*\d+", 3, "SELECT TOP n"),
|
|
43
|
+
("sqlserver", r"(?<![\w\)\]])\[[A-Za-z_][\w ]*\]", 3, "[bracket] identifiers"),
|
|
44
|
+
("sqlserver", r"\bGETDATE\s*\(|\bSYSDATETIME\s*\(", 3, "GETDATE()"),
|
|
45
|
+
("sqlserver", r"\bISNULL\s*\([^,()]+,", 2, "ISNULL(a, b)"),
|
|
46
|
+
("sqlserver", r"\bLEN\s*\(", 2, "LEN()"),
|
|
47
|
+
("sqlserver", r"\bDATEADD\s*\(", 2, "DATEADD()"),
|
|
48
|
+
("sqlserver", r"\bNVARCHAR\b|\bDATETIME2\b|\bUNIQUEIDENTIFIER\b|\bNEWID\s*\(", 2, "SQL Server data types"),
|
|
49
|
+
("sqlserver", r"\bWITH\s*\(\s*NOLOCK\s*\)", 3, "WITH (NOLOCK)"),
|
|
50
|
+
("sqlserver", r"\bIDENTITY\s*\(\s*\d+\s*,\s*\d+\s*\)", 3, "IDENTITY(1,1)"),
|
|
51
|
+
("sqlserver", r"\bOFFSET\s+\d+\s+ROWS?\s+FETCH\b", 2, "OFFSET .. FETCH NEXT"),
|
|
52
|
+
("sqlserver", r"\bdbo\.", 2, "dbo. schema"),
|
|
53
|
+
("sqlserver", r"\bSET\s+NOCOUNT\b|\bEXEC(UTE)?\s+\w", 3, "EXEC / SET NOCOUNT"),
|
|
54
|
+
("sqlserver", r"\b(FROM|INTO|JOIN)\s+#\w+", 3, "#temp tables"),
|
|
55
|
+
("sqlserver", r"\bCONVERT\s*\(\s*\w+(\(\s*\w+\s*\))?\s*,", 2, "CONVERT(type, x)"),
|
|
56
|
+
("sqlserver", r"(?<![\w@])@\w+", 1, "@variables"),
|
|
57
|
+
("sqlserver", r"\bSTRING_AGG\s*\(", 1, "STRING_AGG()"),
|
|
58
|
+
# ---- MySQL -------------------------------------------------------------
|
|
59
|
+
("mysql", r"`[^`]+`", 3, "`backtick` identifiers"),
|
|
60
|
+
("mysql", r"\bLIMIT\s+\d+\s*,\s*\d+", 3, "LIMIT offset, count"),
|
|
61
|
+
("mysql", r"\bIFNULL\s*\(", 2, "IFNULL()"),
|
|
62
|
+
("mysql", r"\bAUTO_INCREMENT\b", 3, "AUTO_INCREMENT"),
|
|
63
|
+
("mysql", r"\bENGINE\s*=", 3, "ENGINE="),
|
|
64
|
+
("mysql", r"\b(CURDATE|CURTIME|UNIX_TIMESTAMP)\s*\(", 2, "CURDATE()/CURTIME()"),
|
|
65
|
+
("mysql", r"\bGROUP_CONCAT\s*\(", 3, "GROUP_CONCAT()"),
|
|
66
|
+
("mysql", r"\bON\s+DUPLICATE\s+KEY\s+UPDATE\b", 3, "ON DUPLICATE KEY UPDATE"),
|
|
67
|
+
("mysql", r"\bINSERT\s+IGNORE\b", 3, "INSERT IGNORE"),
|
|
68
|
+
("mysql", r"\bDATE_FORMAT\s*\(", 2, "DATE_FORMAT()"),
|
|
69
|
+
("mysql", r"^\s*SHOW\s+(TABLES|DATABASES|COLUMNS)\b", 2, "SHOW TABLES"),
|
|
70
|
+
("mysql", r"\bUNSIGNED\b|\bTINYINT\s*\(", 2, "MySQL data types"),
|
|
71
|
+
("mysql", r"\bLIMIT\s+\d+", 1, "LIMIT n"),
|
|
72
|
+
("mysql", r"\bNOW\s*\(\s*\)", 1, "NOW()"),
|
|
73
|
+
("mysql", r"(?<![\w@])@\w+", 1, "@variables"),
|
|
74
|
+
# ---- PostgreSQL --------------------------------------------------------
|
|
75
|
+
("postgresql", r"::\s*\w+", 3, ":: type cast"),
|
|
76
|
+
("postgresql", r"\bILIKE\b", 3, "ILIKE"),
|
|
77
|
+
("postgresql", r"\bRETURNING\b", 3, "RETURNING"),
|
|
78
|
+
("postgresql", r"\b(BIG)?SERIAL\b", 3, "SERIAL"),
|
|
79
|
+
("postgresql", r"\$\d+", 3, "$1 parameters"),
|
|
80
|
+
("postgresql", r"\bGENERATE_SERIES\s*\(", 3, "GENERATE_SERIES()"),
|
|
81
|
+
("postgresql", r"\bJSONB\b", 3, "JSONB"),
|
|
82
|
+
("postgresql", r"\bARRAY\s*\[", 3, "ARRAY[...]"),
|
|
83
|
+
("postgresql", r"\bON\s+CONFLICT\b", 3, "ON CONFLICT"),
|
|
84
|
+
("postgresql", r"\bTO_CHAR\s*\(|\bTO_DATE\s*\(", 2, "TO_CHAR()/TO_DATE()"),
|
|
85
|
+
("postgresql", r"\bSTRING_AGG\s*\(", 2, "STRING_AGG()"),
|
|
86
|
+
("postgresql", r"\|\|", 1, "|| concatenation"),
|
|
87
|
+
("postgresql", r"\bLIMIT\s+\d+", 1, "LIMIT n"),
|
|
88
|
+
("postgresql", r"\bNOW\s*\(\s*\)", 1, "NOW()"),
|
|
89
|
+
# ---- Standard SQL ------------------------------------------------------
|
|
90
|
+
("standard", r"\bFETCH\s+FIRST\s+\d+\s+ROWS?\s+ONLY\b", 2, "FETCH FIRST n ROWS ONLY"),
|
|
91
|
+
("standard", r"\bCURRENT_TIMESTAMP\b", 1, "CURRENT_TIMESTAMP"),
|
|
92
|
+
("standard", r"\bCOALESCE\s*\(", 1, "COALESCE()"),
|
|
93
|
+
("standard", r"\|\|", 1, "|| concatenation"),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
_COMPILED = [(d, re.compile(p, re.I | re.M), w, lbl) for d, p, w, lbl in _RULES]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Detection:
|
|
101
|
+
dialect: str # best guess
|
|
102
|
+
candidates: List[str] # all dialects tied for first place
|
|
103
|
+
scores: Dict[str, int]
|
|
104
|
+
evidence: List[Tuple[str, str]] = field(default_factory=list)
|
|
105
|
+
confidence: str = "low" # low / medium / high
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def display_name(self) -> str:
|
|
109
|
+
return DISPLAY_NAMES[self.dialect]
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def ambiguous(self) -> bool:
|
|
113
|
+
return len(self.candidates) > 1
|
|
114
|
+
|
|
115
|
+
def __str__(self) -> str:
|
|
116
|
+
if self.ambiguous:
|
|
117
|
+
names = " or ".join(DISPLAY_NAMES[c] for c in self.candidates)
|
|
118
|
+
return "Ambiguous: could be %s (confidence: low)" % names
|
|
119
|
+
return "%s (confidence: %s)" % (self.display_name, self.confidence)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def detect_dialect(query: str) -> Detection:
|
|
123
|
+
"""Guess the SQL dialect of `query` by scoring dialect-specific syntax."""
|
|
124
|
+
masked, _ = mask_strings(query)
|
|
125
|
+
scores = {d: 0 for d in DIALECTS}
|
|
126
|
+
evidence: List[Tuple[str, str]] = []
|
|
127
|
+
|
|
128
|
+
for dialect, rx, weight, label in _COMPILED:
|
|
129
|
+
if rx.search(masked):
|
|
130
|
+
scores[dialect] += weight
|
|
131
|
+
evidence.append((dialect, label))
|
|
132
|
+
|
|
133
|
+
top = max(scores.values())
|
|
134
|
+
if top == 0:
|
|
135
|
+
# Nothing dialect-specific found -> plain SQL that runs almost anywhere.
|
|
136
|
+
return Detection("standard", ["standard"], scores, [], "low")
|
|
137
|
+
|
|
138
|
+
candidates = [d for d in DIALECTS if scores[d] == top]
|
|
139
|
+
second = sorted(scores.values(), reverse=True)[1]
|
|
140
|
+
if len(candidates) > 1:
|
|
141
|
+
confidence = "low"
|
|
142
|
+
elif top >= 3 and top >= 2 * second:
|
|
143
|
+
confidence = "high"
|
|
144
|
+
else:
|
|
145
|
+
confidence = "medium"
|
|
146
|
+
return Detection(candidates[0], candidates, scores, evidence, confidence)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""A small Tkinter window that shows a query in every SQL dialect."""
|
|
2
|
+
import tkinter as tk
|
|
3
|
+
from tkinter import ttk
|
|
4
|
+
|
|
5
|
+
from .converter import convert_all
|
|
6
|
+
from .detector import DIALECTS, DISPLAY_NAMES, detect_dialect, normalize_dialect
|
|
7
|
+
|
|
8
|
+
_AUTO = "Auto-detect"
|
|
9
|
+
_MONO = ("Consolas", 11)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def show(query: str = "") -> None:
|
|
13
|
+
"""Open the window. Optionally pre-fill it with `query`."""
|
|
14
|
+
root = tk.Tk()
|
|
15
|
+
root.title("sqlflip - SQL dialect detector & converter")
|
|
16
|
+
root.geometry("920x680")
|
|
17
|
+
root.minsize(700, 520)
|
|
18
|
+
|
|
19
|
+
main = ttk.Frame(root, padding=10)
|
|
20
|
+
main.pack(fill="both", expand=True)
|
|
21
|
+
|
|
22
|
+
# ---- input --------------------------------------------------------------
|
|
23
|
+
ttk.Label(main, text="Paste your SQL query:").pack(anchor="w")
|
|
24
|
+
input_box = tk.Text(main, height=8, font=_MONO, wrap="word", undo=True)
|
|
25
|
+
input_box.pack(fill="x", pady=(2, 8))
|
|
26
|
+
input_box.insert("1.0", query)
|
|
27
|
+
|
|
28
|
+
# ---- controls -----------------------------------------------------------
|
|
29
|
+
controls = ttk.Frame(main)
|
|
30
|
+
controls.pack(fill="x")
|
|
31
|
+
ttk.Label(controls, text="Source dialect:").pack(side="left")
|
|
32
|
+
source_var = tk.StringVar(value=_AUTO)
|
|
33
|
+
ttk.Combobox(
|
|
34
|
+
controls, textvariable=source_var, state="readonly", width=20,
|
|
35
|
+
values=[_AUTO] + [DISPLAY_NAMES[d] for d in DIALECTS],
|
|
36
|
+
).pack(side="left", padx=6)
|
|
37
|
+
|
|
38
|
+
status_var = tk.StringVar(value="Paste a query and click 'Detect & Convert'.")
|
|
39
|
+
ttk.Label(main, textvariable=status_var, font=("Segoe UI", 10, "bold")).pack(anchor="w", pady=(8, 4))
|
|
40
|
+
|
|
41
|
+
# ---- one tab per dialect --------------------------------------------------
|
|
42
|
+
notebook = ttk.Notebook(main)
|
|
43
|
+
notebook.pack(fill="both", expand=True)
|
|
44
|
+
outputs, note_vars = {}, {}
|
|
45
|
+
|
|
46
|
+
def make_copy(d):
|
|
47
|
+
def _copy():
|
|
48
|
+
root.clipboard_clear()
|
|
49
|
+
root.clipboard_append(outputs[d].get("1.0", "end").strip())
|
|
50
|
+
return _copy
|
|
51
|
+
|
|
52
|
+
for d in DIALECTS:
|
|
53
|
+
tab = ttk.Frame(notebook, padding=6)
|
|
54
|
+
notebook.add(tab, text=DISPLAY_NAMES[d])
|
|
55
|
+
box = tk.Text(tab, font=_MONO, wrap="word", state="disabled", bg="#f6f6f6")
|
|
56
|
+
box.pack(fill="both", expand=True)
|
|
57
|
+
outputs[d] = box
|
|
58
|
+
note_vars[d] = tk.StringVar()
|
|
59
|
+
ttk.Label(tab, textvariable=note_vars[d], foreground="#a05a00",
|
|
60
|
+
wraplength=840, justify="left").pack(anchor="w", pady=(6, 0))
|
|
61
|
+
ttk.Button(tab, text="Copy", command=make_copy(d)).pack(anchor="e", pady=(6, 0))
|
|
62
|
+
|
|
63
|
+
# ---- actions -----------------------------------------------------------
|
|
64
|
+
def run():
|
|
65
|
+
q = input_box.get("1.0", "end").strip()
|
|
66
|
+
if not q:
|
|
67
|
+
status_var.set("Please paste a SQL query first.")
|
|
68
|
+
return
|
|
69
|
+
detection = detect_dialect(q)
|
|
70
|
+
chosen = source_var.get()
|
|
71
|
+
if chosen == _AUTO:
|
|
72
|
+
source = detection.dialect
|
|
73
|
+
status_var.set("Detected: %s" % detection)
|
|
74
|
+
else:
|
|
75
|
+
source = next(d for d in DIALECTS if DISPLAY_NAMES[d] == chosen)
|
|
76
|
+
status_var.set("Using source dialect: %s" % chosen)
|
|
77
|
+
|
|
78
|
+
results = convert_all(q, source)
|
|
79
|
+
for i, d in enumerate(DIALECTS):
|
|
80
|
+
box = outputs[d]
|
|
81
|
+
box.config(state="normal")
|
|
82
|
+
box.delete("1.0", "end")
|
|
83
|
+
box.insert("1.0", results[d].sql)
|
|
84
|
+
box.config(state="disabled")
|
|
85
|
+
note_vars[d].set("\n".join("! " + n for n in results[d].notes))
|
|
86
|
+
title = DISPLAY_NAMES[d] + (" (source)" if d == source else "")
|
|
87
|
+
notebook.tab(i, text=title)
|
|
88
|
+
notebook.select(DIALECTS.index(source))
|
|
89
|
+
|
|
90
|
+
def clear():
|
|
91
|
+
input_box.delete("1.0", "end")
|
|
92
|
+
status_var.set("Paste a query and click 'Detect & Convert'.")
|
|
93
|
+
|
|
94
|
+
ttk.Button(controls, text="Detect & Convert", command=run).pack(side="left", padx=6)
|
|
95
|
+
ttk.Button(controls, text="Clear", command=clear).pack(side="left")
|
|
96
|
+
|
|
97
|
+
if query.strip():
|
|
98
|
+
run()
|
|
99
|
+
root.mainloop()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main() -> None:
|
|
103
|
+
show()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
main()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlflipview
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Detect a SQL query's dialect and view it in PostgreSQL, MySQL, SQL Server and standard SQL.
|
|
5
|
+
Author-email: Jyoti <anuvishalp@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/anuvishalp/sqlflip-view
|
|
8
|
+
Project-URL: Source, https://github.com/anuvishalp/sqlflip-view
|
|
9
|
+
Project-URL: Issues, https://github.com/anuvishalp/sqlflip-view/issues
|
|
10
|
+
Keywords: sql,postgresql,mysql,tsql,sqlserver,sql-converter,sql-dialect
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Topic :: Database
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# sqlflip
|
|
23
|
+
|
|
24
|
+
Paste a SQL query and `sqlflip` will:
|
|
25
|
+
|
|
26
|
+
1. **Detect** which dialect it is written in - PostgreSQL, MySQL, SQL Server (T-SQL) or standard SQL.
|
|
27
|
+
2. **Convert** it to the other dialects (identifier quoting, `TOP` / `LIMIT` / `OFFSET .. FETCH`,
|
|
28
|
+
`GETDATE()` / `NOW()`, `ISNULL` / `IFNULL` / `COALESCE`, `LEN` / `LENGTH`, common data types).
|
|
29
|
+
3. **Display** all versions in a small window (Tkinter, no extra dependencies).
|
|
30
|
+
|
|
31
|
+
> T-SQL is the language of SQL Server, so `tsql` and `sqlserver` mean the same thing.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install sqlflip
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
(On Linux you may also need `sudo apt install python3-tk` for the window.)
|
|
40
|
+
|
|
41
|
+
## Use in Python
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from sqlflip import detect_dialect, convert, convert_all, display
|
|
45
|
+
|
|
46
|
+
q = "SELECT TOP 10 [name], ISNULL([age], 0) FROM [users] ORDER BY [name]"
|
|
47
|
+
|
|
48
|
+
print(detect_dialect(q)) # SQL Server (T-SQL) (confidence: high)
|
|
49
|
+
print(convert(q, "postgresql").sql) # SELECT "name", COALESCE("age", 0) FROM "users" ORDER BY "name" LIMIT 10
|
|
50
|
+
print(convert(q, "mysql").sql) # SELECT `name`, IFNULL(`age`, 0) FROM `users` ORDER BY `name` LIMIT 10
|
|
51
|
+
|
|
52
|
+
for dialect, result in convert_all(q).items():
|
|
53
|
+
print(dialect, "->", result.sql, result.notes)
|
|
54
|
+
|
|
55
|
+
display(q) # opens the window
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Command line
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
sqlflip "SELECT TOP 5 * FROM users"
|
|
62
|
+
sqlflip "SELECT * FROM users LIMIT 5, 10" --to sqlserver
|
|
63
|
+
sqlflip --gui
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Limitations
|
|
67
|
+
|
|
68
|
+
This is a lightweight, rule-based tool - **not** a full SQL parser. Always review the output.
|
|
69
|
+
Constructs it cannot safely translate (e.g. `::` casts, `||` / `+` string concatenation,
|
|
70
|
+
auto-increment columns, `GROUP_CONCAT` / `STRING_AGG`, date functions, variables) are left as-is
|
|
71
|
+
and reported in `result.notes`. Detection is heuristic: a query such as `SELECT * FROM t LIMIT 5`
|
|
72
|
+
is valid in both PostgreSQL and MySQL, so it is reported as ambiguous.
|
|
73
|
+
|
|
74
|
+
## Publish to PyPI
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install build twine
|
|
78
|
+
python -m build
|
|
79
|
+
twine upload dist/* # username: __token__ password: <your API token>
|
|
80
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/sqlflip/__init__.py
|
|
5
|
+
src/sqlflip/__main__.py
|
|
6
|
+
src/sqlflip/_utils.py
|
|
7
|
+
src/sqlflip/cli.py
|
|
8
|
+
src/sqlflip/converter.py
|
|
9
|
+
src/sqlflip/detector.py
|
|
10
|
+
src/sqlflip/gui.py
|
|
11
|
+
src/sqlflipview.egg-info/PKG-INFO
|
|
12
|
+
src/sqlflipview.egg-info/SOURCES.txt
|
|
13
|
+
src/sqlflipview.egg-info/dependency_links.txt
|
|
14
|
+
src/sqlflipview.egg-info/entry_points.txt
|
|
15
|
+
src/sqlflipview.egg-info/top_level.txt
|
|
16
|
+
tests/test_sqlflip.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sqlflip
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from sqlflip import convert, convert_all, detect_dialect
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DetectTests(unittest.TestCase):
|
|
7
|
+
def test_sqlserver(self):
|
|
8
|
+
self.assertEqual(detect_dialect("SELECT TOP 10 [name] FROM [users]").dialect, "sqlserver")
|
|
9
|
+
|
|
10
|
+
def test_mysql(self):
|
|
11
|
+
self.assertEqual(detect_dialect("SELECT `name` FROM `users` LIMIT 5, 10").dialect, "mysql")
|
|
12
|
+
|
|
13
|
+
def test_postgres(self):
|
|
14
|
+
self.assertEqual(detect_dialect("SELECT name FROM users WHERE name ILIKE 'a%'").dialect, "postgresql")
|
|
15
|
+
|
|
16
|
+
def test_plain_is_standard(self):
|
|
17
|
+
d = detect_dialect("SELECT name FROM users WHERE id = 1")
|
|
18
|
+
self.assertEqual(d.dialect, "standard")
|
|
19
|
+
|
|
20
|
+
def test_ambiguous_limit(self):
|
|
21
|
+
d = detect_dialect("SELECT name FROM users LIMIT 5")
|
|
22
|
+
self.assertTrue(d.ambiguous)
|
|
23
|
+
|
|
24
|
+
def test_strings_are_ignored(self):
|
|
25
|
+
d = detect_dialect("SELECT 'TOP 5 [x] ILIKE' FROM users")
|
|
26
|
+
self.assertEqual(d.dialect, "standard")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConvertTests(unittest.TestCase):
|
|
30
|
+
Q = "SELECT TOP 10 [name], ISNULL([age], 0) FROM [users] ORDER BY [name]"
|
|
31
|
+
|
|
32
|
+
def test_sqlserver_to_postgres(self):
|
|
33
|
+
r = convert(self.Q, "postgresql")
|
|
34
|
+
self.assertEqual(r.sql, 'SELECT "name", COALESCE("age", 0) FROM "users" ORDER BY "name" LIMIT 10')
|
|
35
|
+
|
|
36
|
+
def test_sqlserver_to_mysql(self):
|
|
37
|
+
r = convert(self.Q, "mysql")
|
|
38
|
+
self.assertEqual(r.sql, "SELECT `name`, IFNULL(`age`, 0) FROM `users` ORDER BY `name` LIMIT 10")
|
|
39
|
+
|
|
40
|
+
def test_mysql_offset_to_sqlserver(self):
|
|
41
|
+
r = convert("SELECT `id` FROM `t` ORDER BY `id` LIMIT 5, 10", "tsql")
|
|
42
|
+
self.assertEqual(r.sql, "SELECT [id] FROM [t] ORDER BY [id] OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY")
|
|
43
|
+
|
|
44
|
+
def test_offset_without_order_by(self):
|
|
45
|
+
r = convert("SELECT id FROM t LIMIT 10 OFFSET 20", "sqlserver", source="postgresql")
|
|
46
|
+
self.assertIn("ORDER BY (SELECT NULL) OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY", r.sql)
|
|
47
|
+
|
|
48
|
+
def test_string_literals_untouched(self):
|
|
49
|
+
r = convert("SELECT TOP 3 'LIMIT 5 [x]' FROM [t]", "postgresql")
|
|
50
|
+
self.assertEqual(r.sql, "SELECT 'LIMIT 5 [x]' FROM \"t\" LIMIT 3")
|
|
51
|
+
|
|
52
|
+
def test_now_and_len(self):
|
|
53
|
+
r = convert("SELECT LEN(name), GETDATE() FROM t", "postgresql", source="sqlserver")
|
|
54
|
+
self.assertEqual(r.sql, "SELECT LENGTH(name), NOW() FROM t")
|
|
55
|
+
|
|
56
|
+
def test_semicolon_kept(self):
|
|
57
|
+
r = convert("SELECT TOP 1 * FROM t;", "mysql", source="sqlserver")
|
|
58
|
+
self.assertEqual(r.sql, "SELECT * FROM t LIMIT 1;")
|
|
59
|
+
|
|
60
|
+
def test_notes_for_cast(self):
|
|
61
|
+
r = convert("SELECT id::text FROM t", "mysql", source="postgresql")
|
|
62
|
+
self.assertTrue(r.notes)
|
|
63
|
+
|
|
64
|
+
def test_convert_all_has_four_dialects(self):
|
|
65
|
+
self.assertEqual(len(convert_all("SELECT 1")), 4)
|
|
66
|
+
|
|
67
|
+
def test_empty_query(self):
|
|
68
|
+
with self.assertRaises(ValueError):
|
|
69
|
+
convert(" ", "mysql")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
unittest.main()
|