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
backend/core/rules.py
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Rule Engine for SQL dialect transformations.
|
|
3
|
+
|
|
4
|
+
Provides a declarative rule-based system for post-processing SQL transformations
|
|
5
|
+
that sqlglot doesn't handle natively.
|
|
6
|
+
"""
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Callable, List, Tuple
|
|
12
|
+
|
|
13
|
+
from .function_call_scanner import replace_function_calls
|
|
14
|
+
from .p1_sql_scanner import executable_segments, mask_non_executable, split_top_level_args
|
|
15
|
+
|
|
16
|
+
# Configure module logger
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _replace_string_agg_to_group_concat(args: str, original: str) -> str:
|
|
21
|
+
"""Convert STRING_AGG to GROUP_CONCAT preserving nested expressions."""
|
|
22
|
+
values = split_top_level_args(args)
|
|
23
|
+
if len(values) != 2 or not all(values):
|
|
24
|
+
return original
|
|
25
|
+
expression, separator = values
|
|
26
|
+
# Strip surrounding quotes from separator if present
|
|
27
|
+
sep_stripped = separator.strip("'\"")
|
|
28
|
+
return f"GROUP_CONCAT({expression} SEPARATOR '{sep_stripped}')"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _replace_group_concat_to_string_agg(args: str, original: str) -> str:
|
|
32
|
+
"""Convert GROUP_CONCAT to STRING_AGG preserving DISTINCT, ORDER BY, SEPARATOR."""
|
|
33
|
+
separator_position = _top_level_keyword(args, "SEPARATOR")
|
|
34
|
+
before_separator = args if separator_position < 0 else args[:separator_position].rstrip()
|
|
35
|
+
separator = "','" if separator_position < 0 else args[separator_position + len("SEPARATOR"):].strip()
|
|
36
|
+
if not separator:
|
|
37
|
+
return original
|
|
38
|
+
|
|
39
|
+
order_position = _top_level_keyword(before_separator, "ORDER BY")
|
|
40
|
+
expression = before_separator if order_position < 0 else before_separator[:order_position].rstrip()
|
|
41
|
+
order_by = None if order_position < 0 else before_separator[order_position + len("ORDER BY"):].strip()
|
|
42
|
+
distinct = bool(re.match(r"^DISTINCT\b", expression, re.IGNORECASE))
|
|
43
|
+
if distinct:
|
|
44
|
+
expression = re.sub(r"^DISTINCT\s+", "", expression, count=1, flags=re.IGNORECASE).strip()
|
|
45
|
+
if not expression:
|
|
46
|
+
return original
|
|
47
|
+
|
|
48
|
+
prefix = "DISTINCT " if distinct else ""
|
|
49
|
+
result = f"STRING_AGG({prefix}{expression}::TEXT, {separator}"
|
|
50
|
+
if order_by:
|
|
51
|
+
result += f" ORDER BY {order_by}"
|
|
52
|
+
return result + ")"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _top_level_keyword(text: str, keyword: str) -> int:
|
|
56
|
+
"""Return the position of a keyword at top-level parentheses depth, or -1."""
|
|
57
|
+
masked = mask_non_executable(text)
|
|
58
|
+
wanted = keyword.upper()
|
|
59
|
+
depth = 0
|
|
60
|
+
for index, char in enumerate(masked):
|
|
61
|
+
if char == "(":
|
|
62
|
+
depth += 1
|
|
63
|
+
continue
|
|
64
|
+
if char == ")" and depth:
|
|
65
|
+
depth -= 1
|
|
66
|
+
continue
|
|
67
|
+
if depth == 0 and masked[index:index + len(wanted)].upper() == wanted:
|
|
68
|
+
before = masked[index - 1] if index else " "
|
|
69
|
+
after_index = index + len(wanted)
|
|
70
|
+
after = masked[after_index] if after_index < len(masked) else " "
|
|
71
|
+
if not (before.isalnum() or before == "_") and not (after.isalnum() or after == "_"):
|
|
72
|
+
return index
|
|
73
|
+
return -1
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _replace_listagg_to_collect_list_wrapper(sql: str, _original_call: str = "") -> str:
|
|
77
|
+
"""Wrapper for LISTAGG conversion that works with the structured replacer interface.
|
|
78
|
+
|
|
79
|
+
NOTE: This wrapper is intentionally a no-op. The LISTAGG conversion is handled
|
|
80
|
+
by the full_sql_rewriter (_replace_listagg_to_collect_list) which operates on
|
|
81
|
+
the full SQL before sqlglot processes it. By the time this wrapper would be
|
|
82
|
+
called, the rule engine has already run and sqlglot may have transformed
|
|
83
|
+
LISTAGG into another function (e.g., GROUP_CONCAT for oracle→hive).
|
|
84
|
+
"""
|
|
85
|
+
return sql
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _replace_listagg_to_collect_list(sql: str) -> tuple[str, bool]:
|
|
89
|
+
"""Convert Oracle LISTAGG to Hive ARRAY_JOIN(COLLECT_LIST(...))."""
|
|
90
|
+
function_name = "LISTAGG"
|
|
91
|
+
masked = mask_non_executable(sql)
|
|
92
|
+
name_upper = function_name.upper()
|
|
93
|
+
length = len(sql)
|
|
94
|
+
result: list[str] = []
|
|
95
|
+
cursor = 0
|
|
96
|
+
index = 0
|
|
97
|
+
applied = False
|
|
98
|
+
|
|
99
|
+
while index < length:
|
|
100
|
+
if masked[index:index + len(function_name)].upper() != name_upper:
|
|
101
|
+
index += 1
|
|
102
|
+
continue
|
|
103
|
+
if index > 0 and (masked[index - 1].isalnum() or masked[index - 1] == "_"):
|
|
104
|
+
index += 1
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
open_index = index + len(function_name)
|
|
108
|
+
while open_index < length and masked[open_index].isspace():
|
|
109
|
+
open_index += 1
|
|
110
|
+
if open_index >= length or masked[open_index] != "(":
|
|
111
|
+
index += 1
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
depth = 0
|
|
115
|
+
close_index = open_index
|
|
116
|
+
while close_index < length:
|
|
117
|
+
char = masked[close_index]
|
|
118
|
+
if char == "(":
|
|
119
|
+
depth += 1
|
|
120
|
+
elif char == ")":
|
|
121
|
+
depth -= 1
|
|
122
|
+
if depth == 0:
|
|
123
|
+
break
|
|
124
|
+
close_index += 1
|
|
125
|
+
if close_index >= length or depth != 0:
|
|
126
|
+
index += 1
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
suffix_index = close_index + 1
|
|
130
|
+
while suffix_index < length and masked[suffix_index].isspace():
|
|
131
|
+
suffix_index += 1
|
|
132
|
+
within_group = "WITHIN" + " " + "GROUP"
|
|
133
|
+
if masked[suffix_index:suffix_index + len(within_group)].upper() != within_group:
|
|
134
|
+
index += 1
|
|
135
|
+
continue
|
|
136
|
+
suffix_index += len(within_group)
|
|
137
|
+
while suffix_index < length and masked[suffix_index].isspace():
|
|
138
|
+
suffix_index += 1
|
|
139
|
+
if suffix_index >= length or masked[suffix_index] != "(":
|
|
140
|
+
index += 1
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
order_depth = 0
|
|
144
|
+
order_close = suffix_index
|
|
145
|
+
while order_close < length:
|
|
146
|
+
char = masked[order_close]
|
|
147
|
+
if char == "(":
|
|
148
|
+
order_depth += 1
|
|
149
|
+
elif char == ")":
|
|
150
|
+
order_depth -= 1
|
|
151
|
+
if order_depth == 0:
|
|
152
|
+
break
|
|
153
|
+
order_close += 1
|
|
154
|
+
if order_close >= length or order_depth != 0:
|
|
155
|
+
index += 1
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
args = sql[open_index + 1:close_index]
|
|
159
|
+
order_clause = sql[suffix_index + 1:order_close].strip()
|
|
160
|
+
if not re.match(r"^ORDER\s+BY\b", order_clause, re.IGNORECASE):
|
|
161
|
+
index += 1
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
values = split_top_level_args(args)
|
|
165
|
+
if len(values) != 2 or not all(values):
|
|
166
|
+
index += 1
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
result.append(sql[cursor:index])
|
|
170
|
+
result.append(f"ARRAY_JOIN(COLLECT_LIST({values[0]}), {values[1]})")
|
|
171
|
+
cursor = order_close + 1
|
|
172
|
+
index = cursor
|
|
173
|
+
applied = True
|
|
174
|
+
|
|
175
|
+
if not applied:
|
|
176
|
+
return sql, False
|
|
177
|
+
result.append(sql[cursor:])
|
|
178
|
+
return "".join(result), True
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class RuleCategory(Enum):
|
|
182
|
+
"""Categories of transformation rules."""
|
|
183
|
+
FUNCTION = "function"
|
|
184
|
+
SYNTAX = "syntax"
|
|
185
|
+
TYPE = "type"
|
|
186
|
+
AGGREGATION = "aggregation"
|
|
187
|
+
DATE_TIME = "date_time"
|
|
188
|
+
JSON = "json"
|
|
189
|
+
ARRAY = "array"
|
|
190
|
+
LIMIT = "limit"
|
|
191
|
+
NULL_HANDLING = "null_handling"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class RuleSafety(Enum):
|
|
195
|
+
"""Safety classification for transformation rules."""
|
|
196
|
+
STRUCTURED = "structured" # scanner-backed function rewrites
|
|
197
|
+
BOUNDED_REGEX = "bounded_regex" # regex with explicit lexical boundaries
|
|
198
|
+
LEGACY_REGEX = "legacy_regex" # plain regex, potentially unsafe
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class TransformRule:
|
|
203
|
+
"""A single transformation rule.
|
|
204
|
+
|
|
205
|
+
Attributes:
|
|
206
|
+
name: Unique rule identifier
|
|
207
|
+
source: Source dialect(s) - can be "*" for any
|
|
208
|
+
target: Target dialect(s) - can be "*" for any
|
|
209
|
+
pattern: Regex pattern to match
|
|
210
|
+
replacement: Replacement string or callable
|
|
211
|
+
note: Human-readable description of the transformation
|
|
212
|
+
category: Rule category for organization
|
|
213
|
+
priority: Higher priority rules are applied first (default 50)
|
|
214
|
+
enabled: Whether the rule is active
|
|
215
|
+
function_name: Optional function name for scanner-backed structural rewrites
|
|
216
|
+
structured_replacement: Optional argument-aware replacement template
|
|
217
|
+
"""
|
|
218
|
+
name: str
|
|
219
|
+
source: str # Can be "hive", "mysql,oracle", or "*"
|
|
220
|
+
target: str # Can be "postgres", "mysql,hive", or "*"
|
|
221
|
+
pattern: str
|
|
222
|
+
replacement: str # Can contain \1, \2 for groups
|
|
223
|
+
note: str
|
|
224
|
+
category: RuleCategory = RuleCategory.FUNCTION
|
|
225
|
+
priority: int = 50
|
|
226
|
+
enabled: bool = True
|
|
227
|
+
function_name: str | None = None
|
|
228
|
+
structured_replacement: str | None = None
|
|
229
|
+
# Custom callable replacer for complex structured rewrites that can't
|
|
230
|
+
# be expressed as a simple format string. Receives (args_str, original_call)
|
|
231
|
+
# and returns the replacement string.
|
|
232
|
+
structured_replacer: Callable[[str, str], str] | None = None
|
|
233
|
+
# For rules that need the full SQL context (e.g., LISTAGG with WITHIN GROUP).
|
|
234
|
+
# Receives (sql,) and returns (transformed_sql, applied_bool).
|
|
235
|
+
full_sql_rewriter: Callable[[str], tuple[str, bool]] | None = None
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def safety(self) -> RuleSafety:
|
|
239
|
+
"""Return the safety classification for this rule."""
|
|
240
|
+
if self.function_name and self.structured_replacement:
|
|
241
|
+
return RuleSafety.STRUCTURED
|
|
242
|
+
# Bounded regex rules use word boundaries and explicit patterns
|
|
243
|
+
if self.pattern and (self.pattern.startswith(r"\b") or
|
|
244
|
+
self.pattern.startswith(r"(?<!)") or
|
|
245
|
+
"WITHIN GROUP" in self.pattern.upper() or
|
|
246
|
+
"LATERAL" in self.pattern.upper()):
|
|
247
|
+
return RuleSafety.BOUNDED_REGEX
|
|
248
|
+
return RuleSafety.LEGACY_REGEX
|
|
249
|
+
|
|
250
|
+
def matches_dialects(self, source: str, target: str) -> bool:
|
|
251
|
+
"""Check if this rule applies to the given dialect pair."""
|
|
252
|
+
source_values = {value.strip().lower() for value in self.source.split(",")}
|
|
253
|
+
target_values = {value.strip().lower() for value in self.target.split(",")}
|
|
254
|
+
source_match = "*" in source_values or source.lower() in source_values
|
|
255
|
+
target_match = "*" in target_values or target.lower() in target_values
|
|
256
|
+
return source_match and target_match
|
|
257
|
+
|
|
258
|
+
def _apply_structured(self, sql: str) -> Tuple[str, bool]:
|
|
259
|
+
"""Apply an explicitly scanner-backed function rewrite."""
|
|
260
|
+
if not self.function_name:
|
|
261
|
+
return sql, False
|
|
262
|
+
|
|
263
|
+
# If a full-SQL rewriter is provided, use it directly.
|
|
264
|
+
if self.full_sql_rewriter is not None:
|
|
265
|
+
return self.full_sql_rewriter(sql)
|
|
266
|
+
|
|
267
|
+
applied = False
|
|
268
|
+
|
|
269
|
+
def replacer(args: str, original_call: str) -> str:
|
|
270
|
+
nonlocal applied
|
|
271
|
+
if self.structured_replacer is not None:
|
|
272
|
+
transformed = self.structured_replacer(args, original_call)
|
|
273
|
+
applied = applied or transformed != original_call
|
|
274
|
+
return transformed
|
|
275
|
+
values = split_top_level_args(args)
|
|
276
|
+
expected = self.structured_replacement.count("{")
|
|
277
|
+
if len(values) != expected:
|
|
278
|
+
return original_call
|
|
279
|
+
applied = True
|
|
280
|
+
return self.structured_replacement.format(*values)
|
|
281
|
+
|
|
282
|
+
transformed = replace_function_calls(sql, self.function_name, replacer)
|
|
283
|
+
return transformed, applied
|
|
284
|
+
|
|
285
|
+
def apply(self, sql: str) -> Tuple[str, bool]:
|
|
286
|
+
"""Apply this rule only to executable SQL segments."""
|
|
287
|
+
if not self.enabled:
|
|
288
|
+
return sql, False
|
|
289
|
+
|
|
290
|
+
if self.function_name and (self.structured_replacement or self.structured_replacer or self.full_sql_rewriter):
|
|
291
|
+
return self._apply_structured(sql)
|
|
292
|
+
|
|
293
|
+
pattern = getattr(self, "_compiled_pattern", None)
|
|
294
|
+
if pattern is None:
|
|
295
|
+
pattern = re.compile(self.pattern, re.IGNORECASE)
|
|
296
|
+
self._compiled_pattern = pattern
|
|
297
|
+
|
|
298
|
+
parts = []
|
|
299
|
+
cursor = 0
|
|
300
|
+
applied = False
|
|
301
|
+
for start, end in executable_segments(sql):
|
|
302
|
+
parts.append(sql[cursor:start])
|
|
303
|
+
segment = sql[start:end]
|
|
304
|
+
transformed, count = pattern.subn(self.replacement, segment)
|
|
305
|
+
parts.append(transformed)
|
|
306
|
+
applied = applied or count > 0
|
|
307
|
+
cursor = end
|
|
308
|
+
|
|
309
|
+
parts.append(sql[cursor:])
|
|
310
|
+
return "".join(parts), applied
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# =============================================================================
|
|
314
|
+
# Rule Definitions - Organized by Category
|
|
315
|
+
# =============================================================================
|
|
316
|
+
|
|
317
|
+
TRANSFORM_RULES: List[TransformRule] = [
|
|
318
|
+
# =========================================================================
|
|
319
|
+
# ARRAY/EXPLODE Rules
|
|
320
|
+
# =========================================================================
|
|
321
|
+
TransformRule(
|
|
322
|
+
name="hive_explode_to_postgres_unnest",
|
|
323
|
+
source="hive,spark",
|
|
324
|
+
target="postgres",
|
|
325
|
+
pattern=r'LATERAL\s+VIEW\s+EXPLODE\s*\((\w+)\)\s+(\w+)\s+AS\s+(\w+)',
|
|
326
|
+
replacement=r'CROSS JOIN LATERAL UNNEST(\1) WITH ORDINALITY AS \2(\3, idx)',
|
|
327
|
+
note="Converted LATERAL VIEW EXPLODE to UNNEST WITH ORDINALITY",
|
|
328
|
+
category=RuleCategory.ARRAY,
|
|
329
|
+
priority=80
|
|
330
|
+
),
|
|
331
|
+
TransformRule(
|
|
332
|
+
name="hive_collect_list_to_postgres",
|
|
333
|
+
source="hive,spark",
|
|
334
|
+
target="postgres",
|
|
335
|
+
pattern=r'COLLECT_LIST\s*\(([^)]+)\)',
|
|
336
|
+
replacement=r'ARRAY_AGG(\1)',
|
|
337
|
+
note="Converted COLLECT_LIST to ARRAY_AGG",
|
|
338
|
+
category=RuleCategory.ARRAY,
|
|
339
|
+
priority=70,
|
|
340
|
+
function_name="COLLECT_LIST",
|
|
341
|
+
structured_replacement="ARRAY_AGG({0})",
|
|
342
|
+
),
|
|
343
|
+
TransformRule(
|
|
344
|
+
name="postgres_array_agg_to_hive",
|
|
345
|
+
source="postgres",
|
|
346
|
+
target="hive,spark",
|
|
347
|
+
pattern=r'ARRAY_AGG\s*\(([^)]+)\)',
|
|
348
|
+
replacement=r'COLLECT_LIST(\1)',
|
|
349
|
+
note="Converted ARRAY_AGG to COLLECT_LIST",
|
|
350
|
+
category=RuleCategory.ARRAY,
|
|
351
|
+
priority=70,
|
|
352
|
+
function_name="ARRAY_AGG",
|
|
353
|
+
structured_replacement="COLLECT_LIST({0})",
|
|
354
|
+
),
|
|
355
|
+
TransformRule(
|
|
356
|
+
name="snowflake_flatten_to_hive",
|
|
357
|
+
source="snowflake",
|
|
358
|
+
target="hive,spark",
|
|
359
|
+
pattern=r'LATERAL\s+FLATTEN\s*\(\s*INPUT\s*=>\s*(\w+)\s*\)\s+(\w+)',
|
|
360
|
+
replacement=r'LATERAL VIEW EXPLODE(\1) \2 AS value',
|
|
361
|
+
note="Converted FLATTEN to LATERAL VIEW EXPLODE",
|
|
362
|
+
category=RuleCategory.ARRAY,
|
|
363
|
+
priority=80
|
|
364
|
+
),
|
|
365
|
+
TransformRule(
|
|
366
|
+
name="snowflake_flatten_to_postgres",
|
|
367
|
+
source="snowflake",
|
|
368
|
+
target="postgres",
|
|
369
|
+
pattern=r'LATERAL\s+FLATTEN\s*\(\s*INPUT\s*=>\s*(\w+)\s*\)\s+(\w+)',
|
|
370
|
+
replacement=r'CROSS JOIN LATERAL UNNEST(\1) AS \2(value)',
|
|
371
|
+
note="Converted FLATTEN to UNNEST",
|
|
372
|
+
category=RuleCategory.ARRAY,
|
|
373
|
+
priority=80
|
|
374
|
+
),
|
|
375
|
+
TransformRule(
|
|
376
|
+
name="hive_size_to_postgres",
|
|
377
|
+
source="hive,spark",
|
|
378
|
+
target="postgres",
|
|
379
|
+
pattern=r'\bSIZE\s*\((\w+)\)',
|
|
380
|
+
replacement=r'ARRAY_LENGTH(\1, 1)',
|
|
381
|
+
note="Converted SIZE to ARRAY_LENGTH",
|
|
382
|
+
category=RuleCategory.ARRAY,
|
|
383
|
+
priority=60
|
|
384
|
+
),
|
|
385
|
+
TransformRule(
|
|
386
|
+
name="postgres_array_length_to_hive",
|
|
387
|
+
source="postgres",
|
|
388
|
+
target="hive,spark",
|
|
389
|
+
pattern=r'ARRAY_LENGTH\s*\((\w+),\s*\d+\)',
|
|
390
|
+
replacement=r'SIZE(\1)',
|
|
391
|
+
note="Converted ARRAY_LENGTH to SIZE",
|
|
392
|
+
category=RuleCategory.ARRAY,
|
|
393
|
+
priority=60
|
|
394
|
+
),
|
|
395
|
+
TransformRule(
|
|
396
|
+
name="duckdb_list_to_hive",
|
|
397
|
+
source="duckdb",
|
|
398
|
+
target="hive,spark",
|
|
399
|
+
pattern=r'\bLIST\b',
|
|
400
|
+
replacement=r'ARRAY',
|
|
401
|
+
note="Converted LIST to ARRAY",
|
|
402
|
+
category=RuleCategory.ARRAY,
|
|
403
|
+
priority=50
|
|
404
|
+
),
|
|
405
|
+
|
|
406
|
+
# =========================================================================
|
|
407
|
+
# AGGREGATION Rules
|
|
408
|
+
# =========================================================================
|
|
409
|
+
TransformRule(
|
|
410
|
+
name="oracle_listagg_to_hive",
|
|
411
|
+
source="oracle",
|
|
412
|
+
target="hive,spark",
|
|
413
|
+
pattern=r"LISTAGG\s*\((\w+)\s*,\s*'([^']+)'\)\s*WITHIN\s+GROUP\s*\(\s*ORDER\s+BY\s+([^)]+)\)",
|
|
414
|
+
replacement=r"ARRAY_JOIN(COLLECT_LIST(\1), '\2')",
|
|
415
|
+
note="Converted LISTAGG to ARRAY_JOIN(COLLECT_LIST()). ORDER BY not preserved",
|
|
416
|
+
category=RuleCategory.AGGREGATION,
|
|
417
|
+
priority=80,
|
|
418
|
+
function_name="LISTAGG",
|
|
419
|
+
full_sql_rewriter=_replace_listagg_to_collect_list,
|
|
420
|
+
),
|
|
421
|
+
TransformRule(
|
|
422
|
+
name="tsql_string_agg_to_mysql",
|
|
423
|
+
source="tsql",
|
|
424
|
+
target="mysql",
|
|
425
|
+
pattern=r"STRING_AGG\s*\((\w+)\s*,\s*'([^']+)'\)",
|
|
426
|
+
replacement=r"GROUP_CONCAT(\1 SEPARATOR '\2')",
|
|
427
|
+
note="Converted STRING_AGG to GROUP_CONCAT",
|
|
428
|
+
category=RuleCategory.AGGREGATION,
|
|
429
|
+
priority=70,
|
|
430
|
+
function_name="STRING_AGG",
|
|
431
|
+
structured_replacer=_replace_string_agg_to_group_concat,
|
|
432
|
+
),
|
|
433
|
+
TransformRule(
|
|
434
|
+
name="mysql_group_concat_to_postgres",
|
|
435
|
+
source="mysql",
|
|
436
|
+
target="postgres",
|
|
437
|
+
pattern=r"GROUP_CONCAT\s*\((\w+)(?:\s+SEPARATOR\s+'([^']+)')?\)",
|
|
438
|
+
replacement=r"STRING_AGG(\1::TEXT, '\2')",
|
|
439
|
+
note="Converted GROUP_CONCAT to STRING_AGG",
|
|
440
|
+
category=RuleCategory.AGGREGATION,
|
|
441
|
+
priority=70,
|
|
442
|
+
function_name="GROUP_CONCAT",
|
|
443
|
+
structured_replacer=_replace_group_concat_to_string_agg,
|
|
444
|
+
),
|
|
445
|
+
TransformRule(
|
|
446
|
+
name="postgres_string_agg_to_mysql",
|
|
447
|
+
source="postgres",
|
|
448
|
+
target="mysql",
|
|
449
|
+
pattern=r"STRING_AGG\s*\(([^,]+),\s*'([^']+)'\)",
|
|
450
|
+
replacement=r"GROUP_CONCAT(\1 SEPARATOR '\2')",
|
|
451
|
+
note="Converted STRING_AGG to GROUP_CONCAT",
|
|
452
|
+
category=RuleCategory.AGGREGATION,
|
|
453
|
+
priority=70,
|
|
454
|
+
function_name="STRING_AGG",
|
|
455
|
+
structured_replacer=_replace_string_agg_to_group_concat,
|
|
456
|
+
),
|
|
457
|
+
# =========================================================================
|
|
458
|
+
# NULL HANDLING Rules
|
|
459
|
+
# =========================================================================
|
|
460
|
+
TransformRule(
|
|
461
|
+
name="mysql_ifnull_to_coalesce",
|
|
462
|
+
source="mysql",
|
|
463
|
+
target="postgres,oracle,hive,spark,trino,snowflake",
|
|
464
|
+
pattern=r"IFNULL\s*\(([^,]+),\s*([^)]+)\)",
|
|
465
|
+
replacement=r"COALESCE(\1, \2)",
|
|
466
|
+
note="Converted IFNULL to COALESCE",
|
|
467
|
+
category=RuleCategory.NULL_HANDLING,
|
|
468
|
+
priority=60,
|
|
469
|
+
function_name="IFNULL",
|
|
470
|
+
structured_replacement="COALESCE({0}, {1})",
|
|
471
|
+
),
|
|
472
|
+
TransformRule(
|
|
473
|
+
name="oracle_nvl_to_coalesce",
|
|
474
|
+
source="oracle",
|
|
475
|
+
target="mysql,postgres,hive,spark,tsql,trino,snowflake",
|
|
476
|
+
pattern=r"\bNVL\s*\(([^,]+),\s*([^)]+)\)",
|
|
477
|
+
replacement=r"COALESCE(\1, \2)",
|
|
478
|
+
note="Converted NVL to COALESCE",
|
|
479
|
+
category=RuleCategory.NULL_HANDLING,
|
|
480
|
+
priority=60,
|
|
481
|
+
function_name="NVL",
|
|
482
|
+
structured_replacement="COALESCE({0}, {1})",
|
|
483
|
+
),
|
|
484
|
+
TransformRule(
|
|
485
|
+
name="tsql_isnull_to_coalesce",
|
|
486
|
+
source="tsql",
|
|
487
|
+
target="mysql,postgres,hive,oracle,spark,trino,snowflake",
|
|
488
|
+
pattern=r"\bISNULL\s*\(([^,]+),\s*([^)]+)\)",
|
|
489
|
+
replacement=r"COALESCE(\1, \2)",
|
|
490
|
+
note="Converted ISNULL to COALESCE",
|
|
491
|
+
category=RuleCategory.NULL_HANDLING,
|
|
492
|
+
priority=60,
|
|
493
|
+
function_name="ISNULL",
|
|
494
|
+
structured_replacement="COALESCE({0}, {1})",
|
|
495
|
+
),
|
|
496
|
+
|
|
497
|
+
# =========================================================================
|
|
498
|
+
# DATE/TIME Rules
|
|
499
|
+
# =========================================================================
|
|
500
|
+
TransformRule(
|
|
501
|
+
name="tsql_getdate_to_mysql",
|
|
502
|
+
source="tsql",
|
|
503
|
+
target="mysql",
|
|
504
|
+
pattern=r"GETDATE\s*\(\)",
|
|
505
|
+
replacement=r"NOW()",
|
|
506
|
+
note="Converted GETDATE to NOW",
|
|
507
|
+
category=RuleCategory.DATE_TIME,
|
|
508
|
+
priority=50
|
|
509
|
+
),
|
|
510
|
+
TransformRule(
|
|
511
|
+
name="tsql_getdate_to_postgres",
|
|
512
|
+
source="tsql,redshift",
|
|
513
|
+
target="postgres",
|
|
514
|
+
pattern=r"GETDATE\s*\(\)",
|
|
515
|
+
replacement=r"CURRENT_TIMESTAMP",
|
|
516
|
+
note="Converted GETDATE to CURRENT_TIMESTAMP",
|
|
517
|
+
category=RuleCategory.DATE_TIME,
|
|
518
|
+
priority=50
|
|
519
|
+
),
|
|
520
|
+
TransformRule(
|
|
521
|
+
name="oracle_sysdate_to_postgres",
|
|
522
|
+
source="oracle",
|
|
523
|
+
target="postgres",
|
|
524
|
+
pattern=r"\bSYSDATE\b",
|
|
525
|
+
replacement=r"CURRENT_TIMESTAMP",
|
|
526
|
+
note="Converted SYSDATE to CURRENT_TIMESTAMP",
|
|
527
|
+
category=RuleCategory.DATE_TIME,
|
|
528
|
+
priority=50
|
|
529
|
+
),
|
|
530
|
+
TransformRule(
|
|
531
|
+
name="mysql_now_to_oracle",
|
|
532
|
+
source="mysql",
|
|
533
|
+
target="oracle",
|
|
534
|
+
pattern=r"NOW\s*\(\)",
|
|
535
|
+
replacement=r"SYSDATE",
|
|
536
|
+
note="Converted NOW to SYSDATE",
|
|
537
|
+
category=RuleCategory.DATE_TIME,
|
|
538
|
+
priority=50
|
|
539
|
+
),
|
|
540
|
+
TransformRule(
|
|
541
|
+
name="hive_unix_timestamp_to_postgres",
|
|
542
|
+
source="hive,spark",
|
|
543
|
+
target="postgres",
|
|
544
|
+
pattern=r"UNIX_TIMESTAMP\s*\(\)",
|
|
545
|
+
replacement=r"EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT",
|
|
546
|
+
note="Converted UNIX_TIMESTAMP to EXTRACT(EPOCH)",
|
|
547
|
+
category=RuleCategory.DATE_TIME,
|
|
548
|
+
priority=60
|
|
549
|
+
),
|
|
550
|
+
|
|
551
|
+
# =========================================================================
|
|
552
|
+
# LIMIT/TOP Rules
|
|
553
|
+
# =========================================================================
|
|
554
|
+
TransformRule(
|
|
555
|
+
name="mysql_limit_offset_to_postgres",
|
|
556
|
+
source="mysql",
|
|
557
|
+
target="postgres",
|
|
558
|
+
pattern=r"LIMIT\s+(\d+)\s*,\s*(\d+)",
|
|
559
|
+
replacement=r"LIMIT \2 OFFSET \1",
|
|
560
|
+
note="Converted MySQL LIMIT offset,count to PostgreSQL LIMIT...OFFSET",
|
|
561
|
+
category=RuleCategory.LIMIT,
|
|
562
|
+
priority=70
|
|
563
|
+
),
|
|
564
|
+
|
|
565
|
+
# =========================================================================
|
|
566
|
+
# JSON Rules
|
|
567
|
+
# =========================================================================
|
|
568
|
+
TransformRule(
|
|
569
|
+
name="mysql_json_extract_to_postgres",
|
|
570
|
+
source="mysql",
|
|
571
|
+
target="postgres",
|
|
572
|
+
pattern=r"JSON_EXTRACT\s*\((\w+),\s*'\$\.(\w+)'\)",
|
|
573
|
+
replacement=r"\1->>'\2'",
|
|
574
|
+
note="Converted JSON_EXTRACT to ->> operator",
|
|
575
|
+
category=RuleCategory.JSON,
|
|
576
|
+
priority=60
|
|
577
|
+
),
|
|
578
|
+
TransformRule(
|
|
579
|
+
name="snowflake_parse_json_to_postgres",
|
|
580
|
+
source="snowflake",
|
|
581
|
+
target="postgres",
|
|
582
|
+
pattern=r"PARSE_JSON\s*\(([^)]+)\)",
|
|
583
|
+
replacement=r"\1::JSONB",
|
|
584
|
+
note="Converted PARSE_JSON to ::JSONB cast",
|
|
585
|
+
category=RuleCategory.JSON,
|
|
586
|
+
priority=60
|
|
587
|
+
),
|
|
588
|
+
|
|
589
|
+
# =========================================================================
|
|
590
|
+
# STRING Rules
|
|
591
|
+
# =========================================================================
|
|
592
|
+
TransformRule(
|
|
593
|
+
name="oracle_substr_to_mysql",
|
|
594
|
+
source="oracle",
|
|
595
|
+
target="mysql",
|
|
596
|
+
pattern=r"\bSUBSTR\s*\(",
|
|
597
|
+
replacement=r"SUBSTRING(",
|
|
598
|
+
note="Converted SUBSTR to SUBSTRING",
|
|
599
|
+
category=RuleCategory.FUNCTION,
|
|
600
|
+
priority=40
|
|
601
|
+
),
|
|
602
|
+
TransformRule(
|
|
603
|
+
name="mysql_substring_to_oracle",
|
|
604
|
+
source="mysql",
|
|
605
|
+
target="oracle",
|
|
606
|
+
pattern=r"\bSUBSTRING\s*\(",
|
|
607
|
+
replacement=r"SUBSTR(",
|
|
608
|
+
note="Converted SUBSTRING to SUBSTR",
|
|
609
|
+
category=RuleCategory.FUNCTION,
|
|
610
|
+
priority=40
|
|
611
|
+
),
|
|
612
|
+
|
|
613
|
+
# =========================================================================
|
|
614
|
+
# TYPE Rules
|
|
615
|
+
# =========================================================================
|
|
616
|
+
TransformRule(
|
|
617
|
+
name="trino_row_to_hive",
|
|
618
|
+
source="trino",
|
|
619
|
+
target="hive,spark",
|
|
620
|
+
pattern=r"\bROW\s*\(",
|
|
621
|
+
replacement=r"STRUCT(",
|
|
622
|
+
note="Converted ROW to STRUCT",
|
|
623
|
+
category=RuleCategory.TYPE,
|
|
624
|
+
priority=50
|
|
625
|
+
),
|
|
626
|
+
TransformRule(
|
|
627
|
+
name="hive_struct_to_trino",
|
|
628
|
+
source="hive,spark",
|
|
629
|
+
target="trino",
|
|
630
|
+
pattern=r"\bSTRUCT\s*\(",
|
|
631
|
+
replacement=r"ROW(",
|
|
632
|
+
note="Converted STRUCT to ROW",
|
|
633
|
+
category=RuleCategory.TYPE,
|
|
634
|
+
priority=50
|
|
635
|
+
),
|
|
636
|
+
]
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
class RuleEngine:
|
|
640
|
+
"""Engine for applying transformation rules."""
|
|
641
|
+
|
|
642
|
+
def __init__(self, rules: List[TransformRule] = None):
|
|
643
|
+
"""Initialize with rules.
|
|
644
|
+
|
|
645
|
+
Args:
|
|
646
|
+
rules: List of transformation rules. Uses default rules if None.
|
|
647
|
+
"""
|
|
648
|
+
self.rules = list(TRANSFORM_RULES if rules is None else rules)
|
|
649
|
+
self._sort_rules()
|
|
650
|
+
self._compile_patterns()
|
|
651
|
+
|
|
652
|
+
def apply_rules(self, sql: str, source: str, target: str) -> Tuple[str, List[str]]:
|
|
653
|
+
"""Apply all matching rules to SQL.
|
|
654
|
+
|
|
655
|
+
Args:
|
|
656
|
+
sql: SQL to transform
|
|
657
|
+
source: Source dialect
|
|
658
|
+
target: Target dialect
|
|
659
|
+
|
|
660
|
+
Returns:
|
|
661
|
+
Tuple of (transformed_sql, list_of_notes)
|
|
662
|
+
"""
|
|
663
|
+
result = sql
|
|
664
|
+
notes = []
|
|
665
|
+
|
|
666
|
+
for rule in self.rules:
|
|
667
|
+
if rule.matches_dialects(source, target):
|
|
668
|
+
new_result, applied = rule.apply(result)
|
|
669
|
+
if applied:
|
|
670
|
+
result = new_result
|
|
671
|
+
notes.append(rule.note)
|
|
672
|
+
|
|
673
|
+
return result, notes
|
|
674
|
+
|
|
675
|
+
def get_applicable_rules(self, source: str, target: str) -> List[TransformRule]:
|
|
676
|
+
"""Get all rules that apply to a dialect pair.
|
|
677
|
+
|
|
678
|
+
Args:
|
|
679
|
+
source: Source dialect
|
|
680
|
+
target: Target dialect
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
List of applicable rules
|
|
684
|
+
"""
|
|
685
|
+
return [r for r in self.rules if r.matches_dialects(source, target)]
|
|
686
|
+
|
|
687
|
+
def add_rule(self, rule: TransformRule) -> None:
|
|
688
|
+
"""Add a new rule.
|
|
689
|
+
|
|
690
|
+
Args:
|
|
691
|
+
rule: Rule to add
|
|
692
|
+
|
|
693
|
+
Raises:
|
|
694
|
+
ValueError: If a rule with the same name already exists.
|
|
695
|
+
re.error: If the rule pattern is invalid.
|
|
696
|
+
"""
|
|
697
|
+
if any(existing.name == rule.name for existing in self.rules):
|
|
698
|
+
raise ValueError(f"Rule name already exists: {rule.name}")
|
|
699
|
+
try:
|
|
700
|
+
rule._compiled_pattern = re.compile(rule.pattern, re.IGNORECASE)
|
|
701
|
+
except re.error as exc:
|
|
702
|
+
raise ValueError(f"Invalid pattern in rule {rule.name}: {exc}") from exc
|
|
703
|
+
self.rules.append(rule)
|
|
704
|
+
self._sort_rules()
|
|
705
|
+
|
|
706
|
+
def disable_rule(self, name: str) -> bool:
|
|
707
|
+
"""Disable a rule by name.
|
|
708
|
+
|
|
709
|
+
Args:
|
|
710
|
+
name: Rule name to disable
|
|
711
|
+
|
|
712
|
+
Returns:
|
|
713
|
+
True if rule was found and disabled
|
|
714
|
+
"""
|
|
715
|
+
for rule in self.rules:
|
|
716
|
+
if rule.name == name:
|
|
717
|
+
rule.enabled = False
|
|
718
|
+
return True
|
|
719
|
+
return False
|
|
720
|
+
|
|
721
|
+
def enable_rule(self, name: str) -> bool:
|
|
722
|
+
"""Enable a rule by name.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
name: Rule name to enable
|
|
726
|
+
|
|
727
|
+
Returns:
|
|
728
|
+
True if rule was found and enabled
|
|
729
|
+
"""
|
|
730
|
+
for rule in self.rules:
|
|
731
|
+
if rule.name == name:
|
|
732
|
+
rule.enabled = True
|
|
733
|
+
return True
|
|
734
|
+
return False
|
|
735
|
+
|
|
736
|
+
def _sort_rules(self) -> None:
|
|
737
|
+
"""Sort rules deterministically: priority first, name as tie-breaker."""
|
|
738
|
+
self.rules.sort(key=lambda rule: (-rule.priority, rule.name))
|
|
739
|
+
|
|
740
|
+
def _compile_patterns(self) -> None:
|
|
741
|
+
"""Pre-compile regex patterns for better performance."""
|
|
742
|
+
for rule in self.rules:
|
|
743
|
+
if not hasattr(rule, '_compiled_pattern'):
|
|
744
|
+
try:
|
|
745
|
+
rule._compiled_pattern = re.compile(rule.pattern, re.IGNORECASE)
|
|
746
|
+
except re.error as e:
|
|
747
|
+
logger.warning(f"Failed to compile pattern for rule {rule.name}: {e}")
|
|
748
|
+
rule._compiled_pattern = None
|
|
749
|
+
|
|
750
|
+
def validate_rules(self) -> List[str]:
|
|
751
|
+
"""Validate rules for common conflicts and invalid configurations.
|
|
752
|
+
|
|
753
|
+
Returns:
|
|
754
|
+
List of warning messages. Empty list means no issues found.
|
|
755
|
+
"""
|
|
756
|
+
warnings = []
|
|
757
|
+
names = set()
|
|
758
|
+
|
|
759
|
+
for rule in self.rules:
|
|
760
|
+
if rule.name in names:
|
|
761
|
+
warnings.append(f"Duplicate rule name: {rule.name}")
|
|
762
|
+
names.add(rule.name)
|
|
763
|
+
|
|
764
|
+
if not rule.pattern:
|
|
765
|
+
warnings.append(f"Empty pattern in rule: {rule.name}")
|
|
766
|
+
|
|
767
|
+
# Check for potentially conflicting rules with same dialect/category
|
|
768
|
+
# Calculate intersection rather than requiring exact selector equality.
|
|
769
|
+
for other in self.rules:
|
|
770
|
+
if rule.name >= other.name:
|
|
771
|
+
continue
|
|
772
|
+
if (rule.category == other.category and
|
|
773
|
+
rule.priority == other.priority and
|
|
774
|
+
self._dialect_sets_intersect(rule.source, other.source) and
|
|
775
|
+
self._dialect_sets_intersect(rule.target, other.target)):
|
|
776
|
+
warnings.append(
|
|
777
|
+
f"Potential conflict: {rule.name} and {other.name} "
|
|
778
|
+
f"have same priority/category and overlapping dialects"
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
return warnings
|
|
782
|
+
|
|
783
|
+
@staticmethod
|
|
784
|
+
def _dialect_sets_intersect(first: str, second: str) -> bool:
|
|
785
|
+
"""Return whether two comma-separated dialect selectors overlap."""
|
|
786
|
+
first_values = {value.strip().lower() for value in first.split(",")}
|
|
787
|
+
second_values = {value.strip().lower() for value in second.split(",")}
|
|
788
|
+
return "*" in first_values or "*" in second_values or bool(first_values & second_values)
|
|
789
|
+
|
|
790
|
+
def get_stats(self) -> dict:
|
|
791
|
+
"""Get rule engine statistics.
|
|
792
|
+
|
|
793
|
+
Returns:
|
|
794
|
+
Dictionary with statistics
|
|
795
|
+
"""
|
|
796
|
+
return {
|
|
797
|
+
"total_rules": len(self.rules),
|
|
798
|
+
"enabled_rules": sum(1 for r in self.rules if r.enabled),
|
|
799
|
+
"categories": {
|
|
800
|
+
cat.value: sum(1 for r in self.rules if r.category == cat)
|
|
801
|
+
for cat in RuleCategory
|
|
802
|
+
},
|
|
803
|
+
"average_priority": sum(r.priority for r in self.rules) / len(self.rules) if self.rules else 0,
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
# Global rule engine instance
|
|
808
|
+
rule_engine = RuleEngine()
|