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.
Files changed (86) hide show
  1. backend/__init__.py +1 -0
  2. backend/api/__init__.py +1 -0
  3. backend/api/main.py +732 -0
  4. backend/api/middleware.py +274 -0
  5. backend/api/rate_limit_store.py +105 -0
  6. backend/api/readiness.py +178 -0
  7. backend/core/__init__.py +57 -0
  8. backend/core/cache.py +261 -0
  9. backend/core/column_hint_validation.py +22 -0
  10. backend/core/config.py +546 -0
  11. backend/core/constants.py +84 -0
  12. backend/core/exceptions.py +124 -0
  13. backend/core/function_call_scanner.py +64 -0
  14. backend/core/function_semantics.py +148 -0
  15. backend/core/functions_db.json +601 -0
  16. backend/core/functions_lookup.py +332 -0
  17. backend/core/metadata.py +71 -0
  18. backend/core/nl2sql.py +85 -0
  19. backend/core/nl2sql_components/__init__.py +72 -0
  20. backend/core/nl2sql_components/aggregations.py +35 -0
  21. backend/core/nl2sql_components/boolean_conditions.py +279 -0
  22. backend/core/nl2sql_components/core_extract.py +106 -0
  23. backend/core/nl2sql_components/evidence.py +122 -0
  24. backend/core/nl2sql_components/mappings.py +185 -0
  25. backend/core/nl2sql_components/relations.py +180 -0
  26. backend/core/nl2sql_components/templates.py +130 -0
  27. backend/core/nl2sql_components/tokenizer.py +187 -0
  28. backend/core/nl2sql_legacy.py +22 -0
  29. backend/core/nl2sql_pipeline/__init__.py +13 -0
  30. backend/core/nl2sql_pipeline/aggregations.py +26 -0
  31. backend/core/nl2sql_pipeline/clause_extract.py +97 -0
  32. backend/core/nl2sql_pipeline/dialect.py +139 -0
  33. backend/core/nl2sql_pipeline/intent.py +85 -0
  34. backend/core/nl2sql_pipeline/ir.py +70 -0
  35. backend/core/nl2sql_pipeline/models.py +30 -0
  36. backend/core/nl2sql_pipeline/orchestrator.py +576 -0
  37. backend/core/nl2sql_pipeline/predicates.py +182 -0
  38. backend/core/nl2sql_pipeline/renderer.py +248 -0
  39. backend/core/nl2sql_pipeline/resolution.py +69 -0
  40. backend/core/nl2sql_pipeline/suggestions.py +30 -0
  41. backend/core/nl2sql_pipeline/templates.py +371 -0
  42. backend/core/nl2sql_pipeline/validation.py +28 -0
  43. backend/core/p1_sql_scanner.py +196 -0
  44. backend/core/p2_data_validation.py +72 -0
  45. backend/core/parser.py +280 -0
  46. backend/core/post_processor.py +129 -0
  47. backend/core/rules.py +808 -0
  48. backend/core/semantic_diff.py +385 -0
  49. backend/core/transpiler.py +791 -0
  50. backend/core/type_mapping.json +593 -0
  51. backend/core/type_mapping.py +765 -0
  52. backend/utils/__init__.py +2 -0
  53. backend/utils/validators.py +407 -0
  54. frontend/__init__.py +10 -0
  55. frontend/app_context_v2.py +201 -0
  56. frontend/core/__init__.py +33 -0
  57. frontend/core/design_tokens.py +127 -0
  58. frontend/core/escaping.py +13 -0
  59. frontend/core/navigation.py +135 -0
  60. frontend/core/state.py +612 -0
  61. frontend/core/styles.py +523 -0
  62. frontend/core/themes.py +69 -0
  63. frontend/core/viewmodels.py +499 -0
  64. frontend/pages/__init__.py +0 -0
  65. frontend/pages/convert.py +283 -0
  66. frontend/pages/diff.py +157 -0
  67. frontend/pages/functions.py +100 -0
  68. frontend/pages/history.py +144 -0
  69. frontend/pages/lineage.py +168 -0
  70. frontend/pages/nl2sql.py +305 -0
  71. frontend/pages/query_analysis.py +120 -0
  72. frontend/pages/runtime.py +66 -0
  73. frontend/pages/settings.py +60 -0
  74. frontend/pages/templates.py +67 -0
  75. frontend/pages/types.py +110 -0
  76. frontend/templates_v2.py +53 -0
  77. frontend/ui/__init__.py +0 -0
  78. frontend/ui/command_palette.py +153 -0
  79. frontend/ui/diff.py +275 -0
  80. frontend/ui/editor.py +102 -0
  81. frontend/ui/status.py +255 -0
  82. sql_dialect_master-1.1.1.dist-info/METADATA +360 -0
  83. sql_dialect_master-1.1.1.dist-info/RECORD +86 -0
  84. sql_dialect_master-1.1.1.dist-info/WHEEL +5 -0
  85. sql_dialect_master-1.1.1.dist-info/licenses/LICENSE +21 -0
  86. 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")