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
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()