sql-dialect-master 1.1.1__py3-none-any.whl
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.
- backend/__init__.py +1 -0
- backend/api/__init__.py +1 -0
- backend/api/main.py +732 -0
- backend/api/middleware.py +274 -0
- backend/api/rate_limit_store.py +105 -0
- backend/api/readiness.py +178 -0
- backend/core/__init__.py +57 -0
- backend/core/cache.py +261 -0
- backend/core/column_hint_validation.py +22 -0
- backend/core/config.py +546 -0
- backend/core/constants.py +84 -0
- backend/core/exceptions.py +124 -0
- backend/core/function_call_scanner.py +64 -0
- backend/core/function_semantics.py +148 -0
- backend/core/functions_db.json +601 -0
- backend/core/functions_lookup.py +332 -0
- backend/core/metadata.py +71 -0
- backend/core/nl2sql.py +85 -0
- backend/core/nl2sql_components/__init__.py +72 -0
- backend/core/nl2sql_components/aggregations.py +35 -0
- backend/core/nl2sql_components/boolean_conditions.py +279 -0
- backend/core/nl2sql_components/core_extract.py +106 -0
- backend/core/nl2sql_components/evidence.py +122 -0
- backend/core/nl2sql_components/mappings.py +185 -0
- backend/core/nl2sql_components/relations.py +180 -0
- backend/core/nl2sql_components/templates.py +130 -0
- backend/core/nl2sql_components/tokenizer.py +187 -0
- backend/core/nl2sql_legacy.py +22 -0
- backend/core/nl2sql_pipeline/__init__.py +13 -0
- backend/core/nl2sql_pipeline/aggregations.py +26 -0
- backend/core/nl2sql_pipeline/clause_extract.py +97 -0
- backend/core/nl2sql_pipeline/dialect.py +139 -0
- backend/core/nl2sql_pipeline/intent.py +85 -0
- backend/core/nl2sql_pipeline/ir.py +70 -0
- backend/core/nl2sql_pipeline/models.py +30 -0
- backend/core/nl2sql_pipeline/orchestrator.py +576 -0
- backend/core/nl2sql_pipeline/predicates.py +182 -0
- backend/core/nl2sql_pipeline/renderer.py +248 -0
- backend/core/nl2sql_pipeline/resolution.py +69 -0
- backend/core/nl2sql_pipeline/suggestions.py +30 -0
- backend/core/nl2sql_pipeline/templates.py +371 -0
- backend/core/nl2sql_pipeline/validation.py +28 -0
- backend/core/p1_sql_scanner.py +196 -0
- backend/core/p2_data_validation.py +72 -0
- backend/core/parser.py +280 -0
- backend/core/post_processor.py +129 -0
- backend/core/rules.py +808 -0
- backend/core/semantic_diff.py +385 -0
- backend/core/transpiler.py +791 -0
- backend/core/type_mapping.json +593 -0
- backend/core/type_mapping.py +765 -0
- backend/utils/__init__.py +2 -0
- backend/utils/validators.py +407 -0
- frontend/__init__.py +10 -0
- frontend/app_context_v2.py +201 -0
- frontend/core/__init__.py +33 -0
- frontend/core/design_tokens.py +127 -0
- frontend/core/escaping.py +13 -0
- frontend/core/navigation.py +135 -0
- frontend/core/state.py +612 -0
- frontend/core/styles.py +523 -0
- frontend/core/themes.py +69 -0
- frontend/core/viewmodels.py +499 -0
- frontend/pages/__init__.py +0 -0
- frontend/pages/convert.py +283 -0
- frontend/pages/diff.py +157 -0
- frontend/pages/functions.py +100 -0
- frontend/pages/history.py +144 -0
- frontend/pages/lineage.py +168 -0
- frontend/pages/nl2sql.py +305 -0
- frontend/pages/query_analysis.py +120 -0
- frontend/pages/runtime.py +66 -0
- frontend/pages/settings.py +60 -0
- frontend/pages/templates.py +67 -0
- frontend/pages/types.py +110 -0
- frontend/templates_v2.py +53 -0
- frontend/ui/__init__.py +0 -0
- frontend/ui/command_palette.py +153 -0
- frontend/ui/diff.py +275 -0
- frontend/ui/editor.py +102 -0
- frontend/ui/status.py +255 -0
- sql_dialect_master-1.1.1.dist-info/METADATA +360 -0
- sql_dialect_master-1.1.1.dist-info/RECORD +86 -0
- sql_dialect_master-1.1.1.dist-info/WHEEL +5 -0
- sql_dialect_master-1.1.1.dist-info/licenses/LICENSE +21 -0
- sql_dialect_master-1.1.1.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SQL Transpiler - Convert SQL between different database dialects."""
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
import sqlglot
|
|
11
|
+
from sqlglot import exp
|
|
12
|
+
|
|
13
|
+
from .cache import TTLCache
|
|
14
|
+
from .config import (
|
|
15
|
+
DANGEROUS_SQL_PATTERNS,
|
|
16
|
+
SUPPORTED_DIALECTS,
|
|
17
|
+
WARNING_SQL_PATTERNS,
|
|
18
|
+
get_compatibility_notes,
|
|
19
|
+
settings,
|
|
20
|
+
)
|
|
21
|
+
from .exceptions import ErrorCode, ValidationError
|
|
22
|
+
from .p1_sql_scanner import mask_non_executable
|
|
23
|
+
from .post_processor import PostProcessor
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
_DANGEROUS_OPERATION_PATTERN = re.compile(
|
|
27
|
+
r"\b(DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE)\b", re.IGNORECASE
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _contains_sql_keyword(sql: str, keyword: str) -> bool:
|
|
32
|
+
"""Return true when a keyword appears as a standalone SQL token."""
|
|
33
|
+
return re.search(rf"\b{re.escape(keyword)}\b", sql, re.IGNORECASE) is not None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _dml_without_where(sql: str, parsed_statements=None):
|
|
37
|
+
"""Return DML operations without WHERE, reusing parsed statements when provided."""
|
|
38
|
+
if parsed_statements is None:
|
|
39
|
+
try:
|
|
40
|
+
parsed_statements = sqlglot.parse(sql)
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
logger.debug("Security AST parse fallback: %s", exc)
|
|
43
|
+
return []
|
|
44
|
+
result = []
|
|
45
|
+
for tree in parsed_statements:
|
|
46
|
+
if tree is None:
|
|
47
|
+
continue
|
|
48
|
+
for node in tree.walk():
|
|
49
|
+
if isinstance(node, exp.Update) and node.args.get("where") is None:
|
|
50
|
+
result.append("UPDATE")
|
|
51
|
+
elif isinstance(node, exp.Delete) and node.args.get("where") is None:
|
|
52
|
+
result.append("DELETE")
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class TranspileResult:
|
|
58
|
+
"""Result of SQL transpilation.
|
|
59
|
+
|
|
60
|
+
``target_validation_state`` is one of:
|
|
61
|
+
|
|
62
|
+
- ``"target_valid"`` — output parses cleanly under the target dialect
|
|
63
|
+
(or validation was disabled via ``validate=False``).
|
|
64
|
+
- ``"generic_only"`` — target-dialect parser rejected the output but the
|
|
65
|
+
generic SQL parser accepted it. The conversion is retained with a
|
|
66
|
+
compatibility warning, but the success flag must NOT read as a
|
|
67
|
+
target-validated conversion.
|
|
68
|
+
- ``"invalid"`` — neither the target parser nor the generic parser
|
|
69
|
+
accepts the output; ``success`` is ``False``.
|
|
70
|
+
"""
|
|
71
|
+
success: bool
|
|
72
|
+
source_sql: str
|
|
73
|
+
target_sql: Optional[str] = None
|
|
74
|
+
source_dialect: str = ""
|
|
75
|
+
target_dialect: str = ""
|
|
76
|
+
error: Optional[str] = None
|
|
77
|
+
error_code: Optional[str] = None
|
|
78
|
+
compatibility_notes: List[str] = field(default_factory=list)
|
|
79
|
+
transformations: List[str] = field(default_factory=list)
|
|
80
|
+
warnings: List[str] = field(default_factory=list)
|
|
81
|
+
target_validation_state: str = "target_valid"
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
84
|
+
return {
|
|
85
|
+
"success": self.success,
|
|
86
|
+
"source_sql": self.source_sql,
|
|
87
|
+
"target_sql": self.target_sql,
|
|
88
|
+
"source_dialect": self.source_dialect,
|
|
89
|
+
"target_dialect": self.target_dialect,
|
|
90
|
+
"error": self.error,
|
|
91
|
+
"error_code": self.error_code,
|
|
92
|
+
"compatibility_notes": self.compatibility_notes,
|
|
93
|
+
"transformations": self.transformations,
|
|
94
|
+
"warnings": self.warnings,
|
|
95
|
+
"target_validation_state": self.target_validation_state,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SQLTranspiler:
|
|
100
|
+
"""SQL Transpiler using sqlglot with post-processing for edge cases."""
|
|
101
|
+
|
|
102
|
+
def __init__(self):
|
|
103
|
+
self.post_processor = PostProcessor()
|
|
104
|
+
self._cache_enabled = settings.cache_enabled
|
|
105
|
+
self._cache = TTLCache(max_size=settings.cache_max_size, ttl=settings.cache_ttl)
|
|
106
|
+
self._security_enabled = settings.security_check_enabled
|
|
107
|
+
self._rule_cache_snapshot_signature = None
|
|
108
|
+
self._rule_cache_snapshot_version = None
|
|
109
|
+
|
|
110
|
+
def transpile(
|
|
111
|
+
self,
|
|
112
|
+
sql: str,
|
|
113
|
+
source: str,
|
|
114
|
+
target: str,
|
|
115
|
+
pretty: bool = True,
|
|
116
|
+
validate: bool = True,
|
|
117
|
+
) -> TranspileResult:
|
|
118
|
+
if not isinstance(sql, str):
|
|
119
|
+
return TranspileResult(
|
|
120
|
+
success=False,
|
|
121
|
+
source_sql=str(sql),
|
|
122
|
+
source_dialect=source if isinstance(source, str) else str(source),
|
|
123
|
+
target_dialect=target if isinstance(target, str) else str(target),
|
|
124
|
+
error="sql must be a string",
|
|
125
|
+
error_code=ErrorCode.VALIDATION_FAILED.value,
|
|
126
|
+
)
|
|
127
|
+
if not isinstance(source, str):
|
|
128
|
+
return TranspileResult(
|
|
129
|
+
success=False,
|
|
130
|
+
source_sql=sql,
|
|
131
|
+
source_dialect=str(source),
|
|
132
|
+
target_dialect=target if isinstance(target, str) else str(target),
|
|
133
|
+
error="source must be a string",
|
|
134
|
+
error_code=ErrorCode.VALIDATION_FAILED.value,
|
|
135
|
+
)
|
|
136
|
+
if not isinstance(target, str):
|
|
137
|
+
return TranspileResult(
|
|
138
|
+
success=False,
|
|
139
|
+
source_sql=sql,
|
|
140
|
+
source_dialect=source,
|
|
141
|
+
target_dialect=str(target),
|
|
142
|
+
error="target must be a string",
|
|
143
|
+
error_code=ErrorCode.VALIDATION_FAILED.value,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if len(sql) > settings.transpiler_max_sql_length:
|
|
147
|
+
return TranspileResult(
|
|
148
|
+
success=False,
|
|
149
|
+
source_sql=sql[:100] + "...",
|
|
150
|
+
source_dialect=source.strip().lower(),
|
|
151
|
+
target_dialect=target.strip().lower(),
|
|
152
|
+
error=f"SQL exceeds maximum length of {settings.transpiler_max_sql_length} characters",
|
|
153
|
+
error_code=ErrorCode.VALIDATION_FAILED.value,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
source = source.strip().lower()
|
|
157
|
+
target = target.strip().lower()
|
|
158
|
+
|
|
159
|
+
logger.info(f"Transpiling SQL: {source} -> {target}, length={len(sql)}")
|
|
160
|
+
logger.debug(f"Input SQL: {sql[:200]}{'...' if len(sql) > 200 else ''}")
|
|
161
|
+
|
|
162
|
+
security_warnings: List[str] = []
|
|
163
|
+
multiple_statements: Optional[bool] = None
|
|
164
|
+
executable_sql: Optional[str] = None
|
|
165
|
+
security_enabled = settings.security_check_enabled
|
|
166
|
+
self._security_enabled = security_enabled
|
|
167
|
+
if security_enabled:
|
|
168
|
+
security_result = self._validate_security(sql)
|
|
169
|
+
security_warnings = list(security_result["warnings"])
|
|
170
|
+
multiple_statements = security_result.get("multiple_statements")
|
|
171
|
+
executable_sql = security_result.get("executable_sql")
|
|
172
|
+
if security_result["blocked"]:
|
|
173
|
+
logger.warning(f"SQL blocked by security check: {security_result['reason']}")
|
|
174
|
+
return TranspileResult(
|
|
175
|
+
success=False,
|
|
176
|
+
source_sql=sql,
|
|
177
|
+
source_dialect=source,
|
|
178
|
+
target_dialect=target,
|
|
179
|
+
error=f"Security check failed: {security_result['reason']}",
|
|
180
|
+
error_code=ErrorCode.SECURITY_VIOLATION.value,
|
|
181
|
+
warnings=security_warnings
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if source not in SUPPORTED_DIALECTS:
|
|
185
|
+
return TranspileResult(
|
|
186
|
+
success=False, source_sql=sql, source_dialect=source, target_dialect=target,
|
|
187
|
+
error=f"Unsupported source dialect: {source}. Supported: {', '.join(SUPPORTED_DIALECTS)}",
|
|
188
|
+
error_code=ErrorCode.UNSUPPORTED_DIALECT.value
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if target not in SUPPORTED_DIALECTS:
|
|
192
|
+
return TranspileResult(
|
|
193
|
+
success=False, source_sql=sql, source_dialect=source, target_dialect=target,
|
|
194
|
+
error=f"Unsupported target dialect: {target}. Supported: {', '.join(SUPPORTED_DIALECTS)}",
|
|
195
|
+
error_code=ErrorCode.UNSUPPORTED_DIALECT.value
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if not sql.strip():
|
|
199
|
+
return TranspileResult(
|
|
200
|
+
success=False, source_sql=sql, source_dialect=source, target_dialect=target,
|
|
201
|
+
error="Empty SQL statement", error_code=ErrorCode.VALIDATION_FAILED.value
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if self._cache_enabled:
|
|
205
|
+
cache_key = self._cache_key(sql, source, target, pretty, validate)
|
|
206
|
+
cached = self._cache.get(cache_key)
|
|
207
|
+
if cached:
|
|
208
|
+
logger.info("Returning cached result")
|
|
209
|
+
# Guard against stale cache entries from before the
|
|
210
|
+
# target_validation_state field existed.
|
|
211
|
+
cached.setdefault("target_validation_state", "target_valid")
|
|
212
|
+
return TranspileResult(**cached)
|
|
213
|
+
|
|
214
|
+
if multiple_statements is None:
|
|
215
|
+
multiple_statements = self._has_multiple_statements(sql)
|
|
216
|
+
if multiple_statements:
|
|
217
|
+
return TranspileResult(
|
|
218
|
+
success=False,
|
|
219
|
+
source_sql=sql,
|
|
220
|
+
source_dialect=source,
|
|
221
|
+
target_dialect=target,
|
|
222
|
+
error="Multiple SQL statements are not supported; submit one statement per request",
|
|
223
|
+
error_code=ErrorCode.VALIDATION_FAILED.value,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
ast_notes: List[str] = []
|
|
228
|
+
if source == "clickhouse" and target == "postgres":
|
|
229
|
+
ast = sqlglot.parse_one(sql, read="clickhouse")
|
|
230
|
+
ast, group_notes = self._ast_grouparray_to_arrayagg(ast, target)
|
|
231
|
+
ast_notes.extend(group_notes)
|
|
232
|
+
ast, arrayjoin_notes = self._ast_clickhouse_array_join_to_unnest(ast, target)
|
|
233
|
+
ast_notes.extend(arrayjoin_notes)
|
|
234
|
+
transpiled = ast.sql(dialect="postgres", pretty=pretty)
|
|
235
|
+
elif source == "oracle" and target in ("postgres", "mysql", "hive", "spark"):
|
|
236
|
+
ast = sqlglot.parse_one(sql, read="oracle")
|
|
237
|
+
ast, rownum_notes = self._ast_rownum_to_limit(ast, target)
|
|
238
|
+
ast_notes.extend(rownum_notes)
|
|
239
|
+
transpiled = ast.sql(dialect=target, pretty=pretty)
|
|
240
|
+
elif source == "duckdb" and target in ("hive", "spark"):
|
|
241
|
+
ast = sqlglot.parse_one(sql, read="duckdb")
|
|
242
|
+
ast, list_notes = self._ast_duckdb_list_to_array(ast, target)
|
|
243
|
+
ast_notes.extend(list_notes)
|
|
244
|
+
transpiled = ast.sql(dialect=target, pretty=pretty)
|
|
245
|
+
elif source == "mysql" and target == "oracle":
|
|
246
|
+
ast = sqlglot.parse_one(sql, read="mysql")
|
|
247
|
+
ast, now_notes = self._ast_mysql_now_to_sysdate(ast, target)
|
|
248
|
+
ast_notes.extend(now_notes)
|
|
249
|
+
transpiled = ast.sql(dialect="oracle", pretty=pretty)
|
|
250
|
+
else:
|
|
251
|
+
transpiled = sqlglot.transpile(sql, read=source, write=target, pretty=pretty)[0]
|
|
252
|
+
final_sql, transformations = self.post_processor.process(transpiled, source, target)
|
|
253
|
+
if ast_notes:
|
|
254
|
+
transformations = ast_notes + transformations
|
|
255
|
+
compat_notes = self._get_compatibility_notes(source, target, sql)
|
|
256
|
+
if executable_sql is not None:
|
|
257
|
+
warnings = security_warnings + self._generate_warnings_masked(
|
|
258
|
+
executable_sql, source, target
|
|
259
|
+
)
|
|
260
|
+
else:
|
|
261
|
+
warnings = security_warnings + self._generate_warnings(sql, source, target)
|
|
262
|
+
|
|
263
|
+
target_validation_state = "target_valid"
|
|
264
|
+
if validate:
|
|
265
|
+
(
|
|
266
|
+
validation_error,
|
|
267
|
+
validation_warning,
|
|
268
|
+
target_validation_state,
|
|
269
|
+
) = self._validate_output_detailed(final_sql, target)
|
|
270
|
+
if validation_warning:
|
|
271
|
+
warnings.append(validation_warning)
|
|
272
|
+
if validation_error:
|
|
273
|
+
logger.error(f"Output SQL validation failed: {source} -> {target}: {validation_error}")
|
|
274
|
+
return TranspileResult(
|
|
275
|
+
success=False, source_sql=sql, source_dialect=source, target_dialect=target,
|
|
276
|
+
error=validation_error, error_code=ErrorCode.VALIDATION_FAILED.value,
|
|
277
|
+
compatibility_notes=compat_notes, transformations=transformations, warnings=warnings,
|
|
278
|
+
target_validation_state="invalid",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
result = TranspileResult(
|
|
282
|
+
success=True,
|
|
283
|
+
source_sql=sql,
|
|
284
|
+
target_sql=final_sql,
|
|
285
|
+
source_dialect=source,
|
|
286
|
+
target_dialect=target,
|
|
287
|
+
compatibility_notes=compat_notes,
|
|
288
|
+
transformations=transformations,
|
|
289
|
+
warnings=warnings,
|
|
290
|
+
target_validation_state=target_validation_state,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if self._cache_enabled:
|
|
294
|
+
self._cache.set(cache_key, result.to_dict())
|
|
295
|
+
return result
|
|
296
|
+
except Exception as e:
|
|
297
|
+
logger.error(f"Transpile failed: {source} -> {target}, error={str(e)}")
|
|
298
|
+
return TranspileResult(
|
|
299
|
+
success=False, source_sql=sql, source_dialect=source, target_dialect=target,
|
|
300
|
+
error=str(e), error_code=ErrorCode.TRANSPILE_FAILED.value
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
@staticmethod
|
|
304
|
+
def _has_multiple_statements(sql: str) -> bool:
|
|
305
|
+
"""Return true when SQL contains more than one parsed statement."""
|
|
306
|
+
if not isinstance(sql, str) or not sql.strip():
|
|
307
|
+
return False
|
|
308
|
+
if ";" not in sql:
|
|
309
|
+
return False
|
|
310
|
+
if ";" not in mask_non_executable(sql):
|
|
311
|
+
return False
|
|
312
|
+
try:
|
|
313
|
+
return len(sqlglot.parse(sql)) > 1
|
|
314
|
+
except Exception:
|
|
315
|
+
return False
|
|
316
|
+
|
|
317
|
+
def _ast_rownum_to_limit(self, ast: exp.Expression, target: str) -> Tuple[exp.Expression, List[str]]:
|
|
318
|
+
"""AST-first ROWNUM -> LIMIT migration (G6-A1).
|
|
319
|
+
|
|
320
|
+
Operates purely on the parsed AST (no string scanning). Migrates the
|
|
321
|
+
legacy safe ROWNUM -> LIMIT semantics: for a single-SELECT Oracle
|
|
322
|
+
statement with exactly one qualifying ``ROWNUM <= N`` predicate
|
|
323
|
+
(bare integer literal N) as the LHS of a comparison, and no ORDER BY /
|
|
324
|
+
GROUP BY / HAVING / DISTINCT / OR / set operations, remove that
|
|
325
|
+
predicate from WHERE (keeping any AND-companions) and set LIMIT to N.
|
|
326
|
+
Returns ``(ast, notes)`` where ``notes`` is empty unless the
|
|
327
|
+
transformation fired.
|
|
328
|
+
"""
|
|
329
|
+
# Only single-SELECT shapes are in scope; set operations are out.
|
|
330
|
+
if not isinstance(ast, exp.Select):
|
|
331
|
+
return ast, []
|
|
332
|
+
|
|
333
|
+
# Conservative guards: any of these present means we must skip.
|
|
334
|
+
if ast.args.get("order") or ast.args.get("group") or ast.args.get("having"):
|
|
335
|
+
return ast, []
|
|
336
|
+
if ast.args.get("distinct"):
|
|
337
|
+
return ast, []
|
|
338
|
+
|
|
339
|
+
where = ast.args.get("where")
|
|
340
|
+
if where is None:
|
|
341
|
+
return ast, []
|
|
342
|
+
where = where.this
|
|
343
|
+
|
|
344
|
+
# OR anywhere in the WHERE subtree is out of scope.
|
|
345
|
+
if any(isinstance(node, exp.Or) for node in where.walk()):
|
|
346
|
+
return ast, []
|
|
347
|
+
|
|
348
|
+
# Collect every qualifying predicate: LHS is the ROWNUM column,
|
|
349
|
+
# operator is <=, RHS is a bare integer literal.
|
|
350
|
+
qualifying = []
|
|
351
|
+
for node in where.walk():
|
|
352
|
+
if not isinstance(node, exp.LTE):
|
|
353
|
+
continue
|
|
354
|
+
lhs = node.this
|
|
355
|
+
rhs = node.expression
|
|
356
|
+
if not isinstance(lhs, exp.Column) or lhs.name.upper() != "ROWNUM":
|
|
357
|
+
continue
|
|
358
|
+
if not isinstance(rhs, exp.Literal) or rhs.is_string or rhs.is_int is False:
|
|
359
|
+
continue
|
|
360
|
+
qualifying.append((node, rhs))
|
|
361
|
+
|
|
362
|
+
# Exactly one qualifying predicate is required.
|
|
363
|
+
if len(qualifying) != 1:
|
|
364
|
+
return ast, []
|
|
365
|
+
pred, bound = qualifying[0]
|
|
366
|
+
|
|
367
|
+
# Detach the predicate from the WHERE tree, keeping AND-companions.
|
|
368
|
+
# ``limit`` returns a new expression; capture the result explicitly.
|
|
369
|
+
self._remove_predicate_from_where(ast, where, pred)
|
|
370
|
+
ast = ast.limit(bound)
|
|
371
|
+
note = f"ROWNUM <= {bound.this} converted to LIMIT {bound.this} (AST)"
|
|
372
|
+
return ast, [note]
|
|
373
|
+
|
|
374
|
+
def _ast_grouparray_to_arrayagg(self, ast: exp.Expression, target: str) -> Tuple[exp.Expression, List[str]]:
|
|
375
|
+
"""AST-first groupArray → ARRAY_AGG migration (clickhouse → postgres, G6-R2).
|
|
376
|
+
|
|
377
|
+
Walks the parsed AST and replaces every ``exp.AnonymousAggFunc`` whose
|
|
378
|
+
function name (case-insensitive) is ``groupArray`` with
|
|
379
|
+
``exp.ArrayAgg(this=node.expressions[0])``. ``node.expressions[0]`` is
|
|
380
|
+
passed directly as ``this``:
|
|
381
|
+
|
|
382
|
+
- simple argument (``Column``) → ``ARRAY_AGG(x)``
|
|
383
|
+
- DISTINCT argument (``Distinct``) → ``ARRAY_AGG(DISTINCT x)`` (preserved)
|
|
384
|
+
- nested expression (``Anonymous``) → ``ARRAY_AGG(nested_expr)``
|
|
385
|
+
- multiple groupArray calls → one ArrayAgg per call
|
|
386
|
+
|
|
387
|
+
Returns ``(ast, notes)`` where ``notes`` is empty unless at least one
|
|
388
|
+
transformation fired.
|
|
389
|
+
"""
|
|
390
|
+
found = False
|
|
391
|
+
|
|
392
|
+
def _transform(node):
|
|
393
|
+
nonlocal found
|
|
394
|
+
if isinstance(node, exp.AnonymousAggFunc) and node.this.upper() == "GROUPARRAY":
|
|
395
|
+
found = True
|
|
396
|
+
return exp.ArrayAgg(this=node.expressions[0])
|
|
397
|
+
return node
|
|
398
|
+
|
|
399
|
+
ast = ast.transform(_transform)
|
|
400
|
+
if found:
|
|
401
|
+
return ast, ["Converted groupArray to ARRAY_AGG"]
|
|
402
|
+
return ast, []
|
|
403
|
+
|
|
404
|
+
def _ast_clickhouse_array_join_to_unnest(self, ast: exp.Expression, target: str) -> tuple[exp.Expression, list[str]]:
|
|
405
|
+
"""AST-first ClickHouse ARRAY JOIN → UNNEST migration (clickhouse → postgres, P5).
|
|
406
|
+
|
|
407
|
+
ClickHouse's ``ARRAY JOIN`` is a collection-join construct with no
|
|
408
|
+
Postgres equivalent; ``sqlglot.transpile`` passes it through
|
|
409
|
+
verbatim, leaving output that the target dialect cannot execute.
|
|
410
|
+
This transform rewrites every ``exp.Join`` with ``kind == "ARRAY"``
|
|
411
|
+
into a ``CROSS JOIN UNNEST(<collection>) AS <alias>(value)``:
|
|
412
|
+
|
|
413
|
+
- bare form (``ARRAY JOIN arr``) → ``CROSS JOIN UNNEST(arr) AS t(value)``
|
|
414
|
+
- explicit alias (``ARRAY JOIN arr AS x``) → ``CROSS JOIN UNNEST(arr) AS x(value)``
|
|
415
|
+
- qualified column (``o.tags``) → alias preserved, column kept qualified
|
|
416
|
+
|
|
417
|
+
The rewritten ``CROSS JOIN UNNEST(...)`` output is Postgres-valid
|
|
418
|
+
and carries the same per-element expansion semantics.
|
|
419
|
+
"""
|
|
420
|
+
found = False
|
|
421
|
+
|
|
422
|
+
def _build_unnest_join(join_node: exp.Join) -> exp.Join:
|
|
423
|
+
node = join_node.this
|
|
424
|
+
if isinstance(node, exp.Alias):
|
|
425
|
+
user_alias = node.alias
|
|
426
|
+
col = node.this
|
|
427
|
+
else:
|
|
428
|
+
user_alias = None
|
|
429
|
+
col = node
|
|
430
|
+
alias = (
|
|
431
|
+
exp.TableAlias(
|
|
432
|
+
this=exp.Identifier(this=user_alias),
|
|
433
|
+
columns=[exp.Identifier(this="value")],
|
|
434
|
+
)
|
|
435
|
+
if user_alias
|
|
436
|
+
else exp.TableAlias(
|
|
437
|
+
this=exp.Identifier(this="t"),
|
|
438
|
+
columns=[exp.Identifier(this="value")],
|
|
439
|
+
)
|
|
440
|
+
)
|
|
441
|
+
return exp.Join(kind="CROSS", this=exp.Unnest(expressions=[col], alias=alias, offset=False))
|
|
442
|
+
|
|
443
|
+
def _transform(node):
|
|
444
|
+
nonlocal found
|
|
445
|
+
if isinstance(node, exp.Join) and node.kind == "ARRAY":
|
|
446
|
+
found = True
|
|
447
|
+
return _build_unnest_join(node)
|
|
448
|
+
return node
|
|
449
|
+
|
|
450
|
+
ast = ast.transform(_transform)
|
|
451
|
+
if found:
|
|
452
|
+
return ast, ["Converted ARRAY JOIN to CROSS JOIN UNNEST"]
|
|
453
|
+
return ast, []
|
|
454
|
+
|
|
455
|
+
def _ast_duckdb_list_to_array(self, ast: exp.Expression, target: str) -> tuple[exp.Expression, list[str]]:
|
|
456
|
+
"""AST-first DuckDB LIST datatype → ARRAY migration (duckdb → hive/spark, G6-L).
|
|
457
|
+
|
|
458
|
+
Walks the parsed AST and replaces every ``exp.DataType`` whose
|
|
459
|
+
``node.this`` is ``exp.DataType.Type.LIST`` with
|
|
460
|
+
``exp.DataType.build("ARRAY", expressions=node.expressions)``.
|
|
461
|
+
Inner type parameters are preserved:
|
|
462
|
+
|
|
463
|
+
- ``LIST`` → ``ARRAY``
|
|
464
|
+
- ``LIST(INT)`` → ``ARRAY<INT>``
|
|
465
|
+
- ``LIST(VARCHAR(10))`` → ``ARRAY<STRING>`` (VARCHAR→STRING mapping is native)
|
|
466
|
+
- ``LIST(INTERVAL)`` → ``ARRAY<INTERVAL>``
|
|
467
|
+
- nested / multiple casts → each LIST node rewritten independently
|
|
468
|
+
|
|
469
|
+
Function forms ``LIST(x)`` / ``LIST(DISTINCT x)`` parse as
|
|
470
|
+
``exp.ArrayAgg``, not ``exp.DataType``, and are never touched by this
|
|
471
|
+
transform; they remain on the sqlglot-native path.
|
|
472
|
+
"""
|
|
473
|
+
found = False
|
|
474
|
+
|
|
475
|
+
def _transform(node):
|
|
476
|
+
nonlocal found
|
|
477
|
+
if isinstance(node, exp.DataType) and node.this == exp.DataType.Type.LIST:
|
|
478
|
+
found = True
|
|
479
|
+
inner = node.expressions
|
|
480
|
+
return (
|
|
481
|
+
exp.DataType.build("ARRAY", expressions=inner)
|
|
482
|
+
if inner
|
|
483
|
+
else exp.DataType.build("ARRAY")
|
|
484
|
+
)
|
|
485
|
+
return node
|
|
486
|
+
|
|
487
|
+
ast = ast.transform(_transform)
|
|
488
|
+
if found:
|
|
489
|
+
return ast, ["Converted LIST to ARRAY (AST)"]
|
|
490
|
+
return ast, []
|
|
491
|
+
|
|
492
|
+
def _ast_mysql_now_to_sysdate(self, ast: exp.Expression, target: str) -> tuple[exp.Expression, list[str]]:
|
|
493
|
+
"""AST-first MySQL NOW() → Oracle SYSDATE migration (mysql → oracle, G6-R1).
|
|
494
|
+
|
|
495
|
+
Walks the parsed AST and replaces every ``exp.Anonymous`` whose function
|
|
496
|
+
name (case-insensitive) is ``NOW`` and which has **no arguments** with
|
|
497
|
+
``exp.CurrentTimestamp(sysdate=True, join_mark=False)``.
|
|
498
|
+
|
|
499
|
+
``CurrentTimestamp(sysdate=True)`` is a standard sqlglot expression whose
|
|
500
|
+
Oracle-dialect serialization emits bare ``SYSDATE`` — no serializer hack,
|
|
501
|
+
no custom expression class, no dialect modification.
|
|
502
|
+
|
|
503
|
+
- ``NOW()`` → ``SYSDATE``
|
|
504
|
+
- ``NOW(6)`` (precision) → **not matched** (``node.expressions`` non-empty)
|
|
505
|
+
- string literal ``'NOW()'`` → **not matched** (literal is not ``exp.Anonymous``)
|
|
506
|
+
- SQL comment ``-- NOW()`` → **not matched** (comment text is not in the AST)
|
|
507
|
+
|
|
508
|
+
Returns ``(ast, notes)`` where ``notes`` is empty unless at least one
|
|
509
|
+
transformation fired.
|
|
510
|
+
"""
|
|
511
|
+
found = False
|
|
512
|
+
|
|
513
|
+
def _transform(node):
|
|
514
|
+
nonlocal found
|
|
515
|
+
if (
|
|
516
|
+
isinstance(node, exp.Anonymous)
|
|
517
|
+
and node.this.upper() == "NOW"
|
|
518
|
+
and not node.expressions
|
|
519
|
+
):
|
|
520
|
+
found = True
|
|
521
|
+
return exp.CurrentTimestamp(sysdate=True, join_mark=False)
|
|
522
|
+
return node
|
|
523
|
+
|
|
524
|
+
ast = ast.transform(_transform)
|
|
525
|
+
if found:
|
|
526
|
+
return ast, ["Converted NOW to SYSDATE (AST)"]
|
|
527
|
+
return ast, []
|
|
528
|
+
|
|
529
|
+
@staticmethod
|
|
530
|
+
def _remove_predicate_from_where(
|
|
531
|
+
ast: exp.Expression, where: exp.Expression, pred: exp.Expression
|
|
532
|
+
) -> None:
|
|
533
|
+
"""Remove ``pred`` from the WHERE clause of ``ast``.
|
|
534
|
+
|
|
535
|
+
If ``pred`` is the whole WHERE, drop the WHERE entirely. If it is one
|
|
536
|
+
branch of a top-level ``And``, keep the other branch.
|
|
537
|
+
"""
|
|
538
|
+
parent = pred.parent
|
|
539
|
+
if parent is not None and isinstance(parent, exp.And):
|
|
540
|
+
companion = parent.expression if parent.this is pred else parent.this
|
|
541
|
+
where.replace(companion)
|
|
542
|
+
else:
|
|
543
|
+
# pred is the entire WHERE expression.
|
|
544
|
+
ast.set("where", None)
|
|
545
|
+
|
|
546
|
+
def _rule_cache_state(self):
|
|
547
|
+
"""Return a mutation-sensitive structural signature for the current rules."""
|
|
548
|
+
return tuple(
|
|
549
|
+
(
|
|
550
|
+
rule.name,
|
|
551
|
+
rule.source,
|
|
552
|
+
rule.target,
|
|
553
|
+
rule.pattern,
|
|
554
|
+
rule.replacement,
|
|
555
|
+
rule.note,
|
|
556
|
+
rule.category.value,
|
|
557
|
+
rule.priority,
|
|
558
|
+
rule.enabled,
|
|
559
|
+
)
|
|
560
|
+
for rule in self.post_processor.engine.rules
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
def _build_rule_cache_payload(self) -> str:
|
|
564
|
+
"""Build the canonical rule payload used by the existing cache identity."""
|
|
565
|
+
return "\n".join(
|
|
566
|
+
"|".join([
|
|
567
|
+
rule.name, rule.source, rule.target, rule.pattern, rule.replacement,
|
|
568
|
+
rule.note, rule.category.value, str(rule.priority), str(rule.enabled),
|
|
569
|
+
])
|
|
570
|
+
for rule in self.post_processor.engine.rules
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
def _rule_cache_version(self) -> str:
|
|
574
|
+
"""Reuse the rule-version hash until the effective rule state changes."""
|
|
575
|
+
signature = self._rule_cache_state()
|
|
576
|
+
if signature != self._rule_cache_snapshot_signature:
|
|
577
|
+
payload = self._build_rule_cache_payload()
|
|
578
|
+
self._rule_cache_snapshot_signature = signature
|
|
579
|
+
self._rule_cache_snapshot_version = hashlib.sha256(
|
|
580
|
+
payload.encode("utf-8")
|
|
581
|
+
).hexdigest()[:16]
|
|
582
|
+
return self._rule_cache_snapshot_version
|
|
583
|
+
|
|
584
|
+
def _cache_key(self, sql: str, source: str, target: str, pretty: bool, validate: bool = True,) -> str:
|
|
585
|
+
rule_version = self._rule_cache_version()
|
|
586
|
+
security_version = f"{settings.security_check_enabled}|{settings.security_block_dangerous}"
|
|
587
|
+
return f"v4|{rule_version}|{security_version}|{sql}|{source}|{target}|{pretty}|{validate}"
|
|
588
|
+
|
|
589
|
+
def _get_compatibility_notes(self, source: str, target: str, sql: str = "") -> List[str]:
|
|
590
|
+
notes = list(get_compatibility_notes(source, target))
|
|
591
|
+
if not sql:
|
|
592
|
+
return notes
|
|
593
|
+
|
|
594
|
+
# Filter out notes for transformations that were NOT actually applied.
|
|
595
|
+
# sqlglot may have handled the conversion natively, or may have left
|
|
596
|
+
# the source construct unchanged. Only emit notes when the target SQL
|
|
597
|
+
# actually contains the expected transformed output.
|
|
598
|
+
sql_upper = sql.upper()
|
|
599
|
+
filtered = []
|
|
600
|
+
for note in notes:
|
|
601
|
+
upper_note = note.upper()
|
|
602
|
+
if "LISTAGG" in upper_note and "ARRAY_JOIN" not in upper_note:
|
|
603
|
+
# Note claims ARRAY_JOIN conversion but we don't see it in output
|
|
604
|
+
continue
|
|
605
|
+
if "ARRAY_JOIN" in upper_note and "ARRAY_JOIN" not in sql_upper:
|
|
606
|
+
continue
|
|
607
|
+
if "CONNECT BY" in upper_note and "WITH RECURSIVE" not in sql_upper:
|
|
608
|
+
continue
|
|
609
|
+
if "LATERAL VIEW" in upper_note and ("UNNEST" not in sql_upper and "JSON_TABLE" not in sql_upper):
|
|
610
|
+
continue
|
|
611
|
+
filtered.append(note)
|
|
612
|
+
notes = filtered
|
|
613
|
+
|
|
614
|
+
if "LIMIT" in sql_upper and target == "oracle":
|
|
615
|
+
notes.append("Oracle uses FETCH FIRST n ROWS ONLY (12c+) or ROWNUM for LIMIT")
|
|
616
|
+
if "AUTO_INCREMENT" in sql_upper and target != "mysql":
|
|
617
|
+
notes.append("AUTO_INCREMENT syntax varies by database")
|
|
618
|
+
if "LATERAL VIEW" in sql_upper and target not in ["hive", "spark", "databricks"]:
|
|
619
|
+
notes.append("LATERAL VIEW is Hive/Spark specific, converted to UNNEST/JSON_TABLE")
|
|
620
|
+
if "CONNECT BY" in sql_upper and target != "oracle":
|
|
621
|
+
notes.append("CONNECT BY is Oracle specific, converted to WITH RECURSIVE")
|
|
622
|
+
if "MERGE" in sql_upper:
|
|
623
|
+
notes.append("MERGE syntax varies significantly between databases")
|
|
624
|
+
if "PIVOT" in sql_upper or "UNPIVOT" in sql_upper:
|
|
625
|
+
notes.append("PIVOT/UNPIVOT syntax varies by database")
|
|
626
|
+
return notes
|
|
627
|
+
|
|
628
|
+
def _generate_warnings(self, sql: str, source: str, target: str) -> List[str]:
|
|
629
|
+
return self._generate_warnings_masked(mask_non_executable(sql), source, target)
|
|
630
|
+
|
|
631
|
+
@staticmethod
|
|
632
|
+
def _generate_warnings_masked(masked_sql: str, source: str, target: str) -> List[str]:
|
|
633
|
+
sql_upper = masked_sql.upper()
|
|
634
|
+
warnings = []
|
|
635
|
+
if "DROP TABLE" in sql_upper or "TRUNCATE" in sql_upper:
|
|
636
|
+
warnings.append("⚠️ Dangerous operation detected: DROP/TRUNCATE")
|
|
637
|
+
# NOTE: DML-without-WHERE warnings are produced exclusively by
|
|
638
|
+
# _validate_security via the AST-based _dml_without_where() check.
|
|
639
|
+
# The regex-based check below would duplicate those warnings.
|
|
640
|
+
if "SELECT *" in sql_upper:
|
|
641
|
+
warnings.append("💡 Consider specifying columns instead of SELECT *")
|
|
642
|
+
if "CROSS JOIN" in sql_upper:
|
|
643
|
+
warnings.append("💡 CROSS JOIN can produce large result sets")
|
|
644
|
+
if sql_upper.count("JOIN") > 5:
|
|
645
|
+
warnings.append("💡 Query has many JOINs - consider query optimization")
|
|
646
|
+
if source == "hive" and target in ["mysql", "postgres", "oracle"]:
|
|
647
|
+
if "DISTRIBUTE BY" in sql_upper or "CLUSTER BY" in sql_upper:
|
|
648
|
+
warnings.append("⚠️ DISTRIBUTE BY/CLUSTER BY are Hive-specific hints, removed in target")
|
|
649
|
+
if "SORT BY" in sql_upper:
|
|
650
|
+
warnings.append("⚠️ SORT BY is Hive-specific, converted to ORDER BY")
|
|
651
|
+
if source in ["hive", "spark"] and target in ["mysql", "postgres"]:
|
|
652
|
+
if "COLLECT_LIST" in sql_upper or "COLLECT_SET" in sql_upper:
|
|
653
|
+
warnings.append("💡 Array aggregation converted - verify result format")
|
|
654
|
+
return warnings
|
|
655
|
+
|
|
656
|
+
def _validate_output(self, sql: str, dialect: str) -> Optional[str]:
|
|
657
|
+
"""Validate target SQL, preserving the original error-only interface."""
|
|
658
|
+
error, _, _ = self._validate_output_detailed(sql, dialect)
|
|
659
|
+
return error
|
|
660
|
+
|
|
661
|
+
def _validate_output_detailed(
|
|
662
|
+
self, sql: str, dialect: str
|
|
663
|
+
) -> tuple[Optional[str], Optional[str], str]:
|
|
664
|
+
"""Validate target SQL with a generic-parser compatibility fallback.
|
|
665
|
+
|
|
666
|
+
Returns ``(error, warning, target_validation_state)`` where
|
|
667
|
+
``target_validation_state`` is one of ``"target_valid"`` (target
|
|
668
|
+
parser accepted the output), ``"generic_only"`` (target parser
|
|
669
|
+
rejected it but the generic parser accepted it — the conversion is
|
|
670
|
+
retained, never presented as target-validated), or ``"invalid"``
|
|
671
|
+
(neither parser accepts the output).
|
|
672
|
+
"""
|
|
673
|
+
try:
|
|
674
|
+
sqlglot.parse_one(sql, read=dialect)
|
|
675
|
+
return None, None, "target_valid"
|
|
676
|
+
except Exception as target_error:
|
|
677
|
+
target_message = str(target_error)[:100]
|
|
678
|
+
try:
|
|
679
|
+
sqlglot.parse_one(sql)
|
|
680
|
+
except Exception:
|
|
681
|
+
return (
|
|
682
|
+
f"⚠️ Output SQL may have syntax issues: {target_message}",
|
|
683
|
+
None,
|
|
684
|
+
"invalid",
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
warning = (
|
|
688
|
+
"⚠️ Target dialect parser rejected the output, but the generic "
|
|
689
|
+
"SQL parser accepted it; retaining the conversion with a "
|
|
690
|
+
f"compatibility warning. Target parser error: {target_message}"
|
|
691
|
+
)
|
|
692
|
+
logger.warning("Generic parser fallback used for %s output: %s", dialect, target_message)
|
|
693
|
+
return None, warning, "generic_only"
|
|
694
|
+
|
|
695
|
+
def _validate_security(self, sql: str) -> Dict[str, Any]:
|
|
696
|
+
result = {"blocked": False, "reason": None, "warnings": [], "executable_sql": None}
|
|
697
|
+
executable_sql = mask_non_executable(sql)
|
|
698
|
+
result["executable_sql"] = executable_sql
|
|
699
|
+
dangerous_operation = _DANGEROUS_OPERATION_PATTERN.search(executable_sql)
|
|
700
|
+
if dangerous_operation:
|
|
701
|
+
message = f"Dangerous SQL operation detected: {dangerous_operation.group(1).upper()}"
|
|
702
|
+
if settings.security_block_dangerous:
|
|
703
|
+
result["blocked"] = True
|
|
704
|
+
result["reason"] = message
|
|
705
|
+
return result
|
|
706
|
+
result["warnings"].append(f"🔒 Security: {message}")
|
|
707
|
+
if ";" not in sql or ";" not in executable_sql:
|
|
708
|
+
result["multiple_statements"] = False
|
|
709
|
+
else:
|
|
710
|
+
try:
|
|
711
|
+
parsed_statements = sqlglot.parse(sql)
|
|
712
|
+
result["multiple_statements"] = len(parsed_statements) > 1
|
|
713
|
+
if result["multiple_statements"]:
|
|
714
|
+
message = "Multiple SQL statements detected"
|
|
715
|
+
if settings.security_block_dangerous:
|
|
716
|
+
result["blocked"] = True
|
|
717
|
+
result["reason"] = message
|
|
718
|
+
return result
|
|
719
|
+
result["warnings"].append(f"🔒 Security: {message}")
|
|
720
|
+
except Exception as exc:
|
|
721
|
+
logger.debug("SQL statement parsing failed during security validation: %s", exc)
|
|
722
|
+
for pattern, message in DANGEROUS_SQL_PATTERNS:
|
|
723
|
+
if pattern.search(executable_sql):
|
|
724
|
+
if settings.security_block_dangerous:
|
|
725
|
+
result["blocked"] = True
|
|
726
|
+
result["reason"] = message
|
|
727
|
+
return result
|
|
728
|
+
result["warnings"].append(f"🔒 Security: {message}")
|
|
729
|
+
masked_upper = executable_sql.upper()
|
|
730
|
+
needs_dml_ast = bool(re.search(r"\b(?:UPDATE|DELETE)\b", masked_upper))
|
|
731
|
+
dml_parsed = None
|
|
732
|
+
if needs_dml_ast and result.get("multiple_statements") is False:
|
|
733
|
+
try:
|
|
734
|
+
dml_parsed = sqlglot.parse(sql)
|
|
735
|
+
except Exception as exc:
|
|
736
|
+
logger.debug("DML security AST parse unavailable: %s", exc)
|
|
737
|
+
dml_parsed = None
|
|
738
|
+
if dml_parsed is not None:
|
|
739
|
+
dml_without_where = set(_dml_without_where(sql, dml_parsed))
|
|
740
|
+
for op in sorted(dml_without_where):
|
|
741
|
+
result["warnings"].append(f"⚠️ {op} without WHERE clause - may affect all rows")
|
|
742
|
+
for pattern, message in WARNING_SQL_PATTERNS:
|
|
743
|
+
if message in {
|
|
744
|
+
"DELETE without WHERE clause - will affect all rows",
|
|
745
|
+
"UPDATE without WHERE clause - will affect all rows",
|
|
746
|
+
}:
|
|
747
|
+
continue
|
|
748
|
+
if pattern.search(executable_sql):
|
|
749
|
+
result["warnings"].append(f"⚠️ {message}")
|
|
750
|
+
return result
|
|
751
|
+
|
|
752
|
+
def batch_transpile(self, statements: List[str], source: str, target: str, pretty: bool = True) -> List[TranspileResult]:
|
|
753
|
+
if not isinstance(statements, list):
|
|
754
|
+
raise ValidationError("statements must be a list", field="statements", value=type(statements).__name__)
|
|
755
|
+
if len(statements) > settings.max_batch_size:
|
|
756
|
+
raise ValidationError(f"Batch contains {len(statements)} statements; maximum is {settings.max_batch_size}", field="statements", value=str(len(statements)))
|
|
757
|
+
return [self.transpile(sql, source, target, pretty) for sql in statements]
|
|
758
|
+
|
|
759
|
+
async def batch_transpile_async(self, statements: List[str], source: str, target: str, pretty: bool = True, max_concurrent: int = 10) -> List[TranspileResult]:
|
|
760
|
+
if not isinstance(statements, list):
|
|
761
|
+
raise ValidationError("statements must be a list", field="statements", value=type(statements).__name__)
|
|
762
|
+
if len(statements) > settings.max_batch_size:
|
|
763
|
+
raise ValidationError(f"Batch contains {len(statements)} statements; maximum is {settings.max_batch_size}", field="statements", value=str(len(statements)))
|
|
764
|
+
if isinstance(max_concurrent, bool) or not isinstance(max_concurrent, int) or max_concurrent <= 0:
|
|
765
|
+
raise ValidationError("max_concurrent must be a positive integer", field="max_concurrent", value=str(max_concurrent))
|
|
766
|
+
semaphore = asyncio.Semaphore(max_concurrent)
|
|
767
|
+
|
|
768
|
+
async def limited_transpile(sql: str) -> TranspileResult:
|
|
769
|
+
async with semaphore:
|
|
770
|
+
loop = asyncio.get_running_loop()
|
|
771
|
+
return await loop.run_in_executor(None, lambda: self.transpile(sql, source, target, pretty))
|
|
772
|
+
|
|
773
|
+
return await asyncio.gather(*(limited_transpile(sql) for sql in statements))
|
|
774
|
+
|
|
775
|
+
def get_supported_dialects(self) -> List[str]:
|
|
776
|
+
return SUPPORTED_DIALECTS.copy()
|
|
777
|
+
|
|
778
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
779
|
+
return {
|
|
780
|
+
"supported_dialects": len(SUPPORTED_DIALECTS),
|
|
781
|
+
"dialects": SUPPORTED_DIALECTS,
|
|
782
|
+
"post_processor": self.post_processor.get_stats(),
|
|
783
|
+
"cache": self._cache.get_stats() if self._cache_enabled else {"enabled": False},
|
|
784
|
+
"security": {"enabled": self._security_enabled, "block_dangerous": settings.security_block_dangerous},
|
|
785
|
+
"settings": {"cache_enabled": self._cache_enabled, "max_batch_size": settings.max_batch_size, "max_sql_length": settings.transpiler_max_sql_length}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
def clear_cache(self) -> None:
|
|
789
|
+
if self._cache_enabled:
|
|
790
|
+
self._cache.clear()
|
|
791
|
+
logger.info("Transpile cache cleared")
|