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,283 @@
1
+ """Convert Workspace for SQL Dialect Master v2.
2
+
3
+ This is the primary workspace — a professional SQL IDE-style interface
4
+ for dialect conversion with semantic analysis integration.
5
+
6
+ Fixes the batch conversion bug from v1: properly delegates statement
7
+ splitting to the canonical backend API.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from datetime import UTC, datetime
12
+
13
+ import streamlit as st
14
+
15
+ from frontend.app_context_v2 import (
16
+ DIALECTS,
17
+ batch_convert_sql,
18
+ convert_sql,
19
+ copy_sql,
20
+ format_sql_local,
21
+ split_sql_statements,
22
+ )
23
+ from frontend.core.design_tokens import (
24
+ ColorTokens,
25
+ )
26
+ from frontend.core.escaping import esc
27
+ from frontend.core.navigation import create_navigation_intent
28
+ from frontend.core.state import SessionState
29
+ from frontend.core.viewmodels import (
30
+ BatchConversionViewModel,
31
+ ConversionViewModel,
32
+ )
33
+ from frontend.ui.status import render_batch_result, render_result_panel
34
+
35
+
36
+ def render_convert_page(theme: ColorTokens) -> None:
37
+ """Render the Convert workspace page."""
38
+ state = SessionState.get()
39
+
40
+ # ── Toolbar ──────────────────────────────────────────────────────
41
+ _render_toolbar(theme)
42
+
43
+ # ── Dialect selectors + SQL editors ────────────────────────────
44
+ src_dialect = st.selectbox(
45
+ "Source dialect",
46
+ DIALECTS,
47
+ index=DIALECTS.index("postgres") if "postgres" in DIALECTS else 0,
48
+ key="convert_src",
49
+ label_visibility="collapsed",
50
+ )
51
+ tgt_dialect = st.selectbox(
52
+ "Target dialect",
53
+ DIALECTS,
54
+ index=DIALECTS.index("mysql") if "mysql" in DIALECTS else 0,
55
+ key="convert_tgt",
56
+ label_visibility="collapsed",
57
+ )
58
+
59
+ # Swap button between selectors
60
+ col_swap, _ = st.columns([1, 10])
61
+ with col_swap:
62
+ if st.button("⇄", key="swap_dialects", help="Swap source and target dialects"):
63
+ st.session_state.convert_src, st.session_state.convert_tgt = st.session_state.convert_tgt, st.session_state.convert_src
64
+ st.rerun()
65
+
66
+ # ── SQL editors (two-panel) ────────────────────────────────────
67
+ col_src, col_tgt = st.columns([1, 1])
68
+
69
+ with col_src:
70
+ st.markdown(
71
+ f'<div style="font-size:11px; font-weight:600; letter-spacing:0.06em; '
72
+ f'text-transform:uppercase; color:{theme.text_muted}; margin-bottom:6px;">'
73
+ f'Source · {esc(src_dialect.upper())}</div>',
74
+ unsafe_allow_html=True,
75
+ )
76
+ src_sql = st.text_area(
77
+ "Source SQL",
78
+ key="convert_src_sql",
79
+ height=300,
80
+ placeholder="SELECT * FROM users WHERE created_at > NOW() - INTERVAL '7 days'",
81
+ label_visibility="collapsed",
82
+ )
83
+
84
+ col_fmt, col_clear = st.columns([1, 1])
85
+ with col_fmt:
86
+ if st.button("Format", key="format_src", use_container_width=True, type="secondary"):
87
+ formatted = format_sql_local(src_sql, src_dialect)
88
+ st.session_state.convert_src_sql = formatted
89
+ st.rerun()
90
+ with col_clear:
91
+ if st.button("Clear", key="clear_src", use_container_width=True):
92
+ st.session_state.convert_src_sql = ""
93
+ st.rerun()
94
+
95
+ with col_tgt:
96
+ st.markdown(
97
+ f'<div style="font-size:11px; font-weight:600; letter-spacing:0.06em; '
98
+ f'text-transform:uppercase; color:{theme.text_muted}; margin-bottom:6px;">'
99
+ f'Target · {esc(tgt_dialect.upper())}</div>',
100
+ unsafe_allow_html=True,
101
+ )
102
+
103
+ # Show converted SQL or placeholder
104
+ target_sql_display = state.convert_target_sql
105
+ if target_sql_display:
106
+ st.code(target_sql_display, language="sql")
107
+ col_copy, col_dl = st.columns([1, 1])
108
+ with col_copy:
109
+ copy_sql(target_sql_display, key="copy_tgt")
110
+ with col_dl:
111
+ st.download_button(
112
+ "Download",
113
+ target_sql_display,
114
+ "converted.sql",
115
+ mime="text/sql",
116
+ key="download_tgt_sql",
117
+ )
118
+ else:
119
+ st.markdown(
120
+ f'<div style="border:1px dashed {theme.border}; border-radius:6px; '
121
+ f'padding:40px; text-align:center; color:{theme.text_muted};">'
122
+ f'Converted SQL will appear here</div>',
123
+ unsafe_allow_html=True,
124
+ )
125
+
126
+ # ── Convert button ─────────────────────────────────────────────
127
+ convert_clicked = st.button("Convert", key="convert_btn", type="primary", use_container_width=True)
128
+
129
+ if convert_clicked and src_sql.strip():
130
+ with st.spinner("Converting..."):
131
+ result = convert_sql(src_sql, src_dialect, tgt_dialect)
132
+
133
+ vm = ConversionViewModel.from_transpile_result(result)
134
+ state.convert_last_vm = vm
135
+ state.convert_target_sql = vm.target_sql or ""
136
+
137
+ # Add to history
138
+ _add_to_history(state, vm, theme)
139
+ st.rerun()
140
+ elif convert_clicked and not src_sql.strip():
141
+ st.warning("Please enter SQL to convert.")
142
+
143
+ # ── Result display ─────────────────────────────────────────────
144
+ vm: ConversionViewModel | None = state.convert_last_vm
145
+ if vm is not None:
146
+ st.markdown("---")
147
+ render_result_panel(
148
+ sql=vm.target_sql or "",
149
+ source_dialect=vm.source_dialect,
150
+ target_dialect=vm.target_dialect,
151
+ warnings=vm.warnings,
152
+ transformations=vm.transformations,
153
+ compatibility_notes=vm.compatibility_notes,
154
+ error_message=vm.error_message,
155
+ semantic_label=vm.semantic_label,
156
+ semantic_status=vm.semantic_status,
157
+ theme=theme,
158
+ target_validation_state=vm.target_validation_state,
159
+ )
160
+
161
+ # Navigate to Diff workspace
162
+ if vm.target_sql and vm.success:
163
+ col_diff, _ = st.columns([1, 3])
164
+ with col_diff:
165
+ if st.button("Semantic Diff →", key="run_semantic_diff", type="secondary", use_container_width=True):
166
+ intent = create_navigation_intent(
167
+ target_page="diff",
168
+ action="open_diff",
169
+ source=vm.source_sql,
170
+ target=vm.target_sql,
171
+ src_dialect=vm.source_dialect,
172
+ tgt_dialect=vm.target_dialect,
173
+ )
174
+ st.session_state.sdm_navigation_intent = intent
175
+ st.rerun()
176
+
177
+ # ── Batch conversion ───────────────────────────────────────────
178
+ with st.expander("Batch Conversion", expanded=False):
179
+ _render_batch_section(src_dialect, tgt_dialect, theme)
180
+
181
+
182
+ def _render_toolbar(theme: ColorTokens) -> None:
183
+ """Render the top toolbar for the Convert page."""
184
+ st.markdown(
185
+ f"""
186
+ <div style="display:flex; align-items:center; justify-content:space-between;
187
+ padding:12px 0; margin-bottom:16px; border-bottom:1px solid {theme.border};">
188
+ <div>
189
+ <div style="font-size:18px; font-weight:700; color:{theme.text_primary};">
190
+ Convert
191
+ </div>
192
+ <div style="font-size:12px; color:{theme.text_muted}; margin-top:2px;">
193
+ Transform SQL between database dialects
194
+ </div>
195
+ </div>
196
+ <div style="display:flex; gap:8px;">
197
+ <span style="font-size:11px; color:{theme.text_muted}; font-family:monospace;">
198
+ Click Convert to run
199
+ </span>
200
+ </div>
201
+ </div>
202
+ """,
203
+ unsafe_allow_html=True,
204
+ )
205
+
206
+
207
+ def _render_batch_section(src_dialect: str, tgt_dialect: str, theme: ColorTokens) -> None:
208
+ """Render the batch conversion section."""
209
+ state = SessionState.get()
210
+ st.markdown(
211
+ '<div style="font-size:11px; font-weight:600; letter-spacing:0.06em; '
212
+ 'text-transform:uppercase; color:' + theme.text_muted + '; margin-bottom:8px;">'
213
+ 'Batch Mode</div>',
214
+ unsafe_allow_html=True,
215
+ )
216
+
217
+ batch_sql = st.text_area(
218
+ "Enter SQL statements (one per line or separated by semicolons)",
219
+ key="batch_sql_input",
220
+ height=150,
221
+ placeholder="SELECT * FROM users;\nSELECT id, name FROM orders WHERE status = 'active';",
222
+ )
223
+
224
+ col_bs, col_bt, col_bb = st.columns([1, 1, 2])
225
+ with col_bs:
226
+ batch_src = st.selectbox("Source", DIALECTS, index=DIALECTS.index("postgres") if "postgres" in DIALECTS else 0,
227
+ key="batch_src", label_visibility="collapsed")
228
+ with col_bt:
229
+ batch_tgt = st.selectbox("Target", DIALECTS, index=DIALECTS.index("mysql") if "mysql" in DIALECTS else 0,
230
+ key="batch_tgt", label_visibility="collapsed")
231
+ with col_bb:
232
+ if st.button("Convert All", key="batch_convert_btn", type="primary", use_container_width=True):
233
+ if not batch_sql.strip():
234
+ st.warning("Please enter SQL statements.")
235
+ else:
236
+ with st.spinner("Batch converting..."):
237
+ # Use canonical backend for proper semicolon splitting
238
+ statements = split_sql_statements(batch_sql, batch_src)
239
+ results = batch_convert_sql(statements, batch_src, batch_tgt)
240
+ bvm = BatchConversionViewModel.from_results(statements, results)
241
+ state.batch_last_vm = bvm
242
+ st.rerun()
243
+
244
+ bvm: BatchConversionViewModel | None = state.batch_last_vm
245
+ if bvm is not None:
246
+ render_batch_result(
247
+ total=bvm.total,
248
+ succeeded=bvm.succeeded,
249
+ failed=bvm.failed,
250
+ items=bvm.items,
251
+ theme=theme,
252
+ )
253
+
254
+ # Download all
255
+ combined = "\n\n".join(
256
+ item.converted_sql or f"-- Statement {item.statement_index} failed"
257
+ for item in bvm.items
258
+ )
259
+ st.download_button(
260
+ "Download All",
261
+ combined,
262
+ "batch_converted.sql",
263
+ mime="text/sql",
264
+ use_container_width=True,
265
+ )
266
+
267
+
268
+ def _add_to_history(
269
+ state: SessionState,
270
+ vm: ConversionViewModel,
271
+ theme: ColorTokens,
272
+ ) -> None:
273
+ """Add a successful conversion to session history via SessionState."""
274
+ if not vm.success:
275
+ return
276
+ state.add_history_entry(
277
+ sql=vm.source_sql,
278
+ source_dialect=vm.source_dialect,
279
+ target_dialect=vm.target_dialect,
280
+ result_sql=vm.target_sql,
281
+ status=vm.status,
282
+ created_at=datetime.now(UTC).isoformat(),
283
+ )
frontend/pages/diff.py ADDED
@@ -0,0 +1,157 @@
1
+ """Semantic Diff Workspace for SQL Dialect Master v2.
2
+
3
+ Full workspace with:
4
+ - Overall semantic classification banner
5
+ - Finding list (clickable, with severity indicators)
6
+ - Inspector panel (shows selected finding details)
7
+ - Semantic vs Text diff tab switcher
8
+ - Integration with Convert workspace via unified NavigationIntent
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import streamlit as st
13
+
14
+ from backend.core.semantic_diff import diff_sql_ast
15
+ from frontend.app_context_v2 import DIALECTS
16
+ from frontend.core.design_tokens import ColorTokens
17
+ from frontend.core.navigation import consume_navigation_intent
18
+ from frontend.core.state import SessionState
19
+ from frontend.core.viewmodels import SemanticDiffViewModel
20
+
21
+ _FINDING_KEY = "sdm_selected_finding_index"
22
+
23
+
24
+ def render_diff_page(theme: ColorTokens) -> None:
25
+ """Render the Semantic Diff workspace."""
26
+ state = SessionState.get()
27
+
28
+ _render_header(theme)
29
+ _render_input_section(theme)
30
+
31
+ # Process navigation intent from Convert → Diff handoff
32
+ intent = consume_navigation_intent()
33
+ if intent and intent.action == "open_diff" and not state.diff_last_vm:
34
+ st.session_state.diff_src_sql = intent.get_payload("source", "")
35
+ st.session_state.diff_tgt_sql = intent.get_payload("target", "")
36
+ st.session_state.diff_src_dialect = intent.get_payload("src_dialect", "postgres")
37
+ st.session_state.diff_tgt_dialect = intent.get_payload("tgt_dialect", "mysql")
38
+ state.selected_finding_index = None
39
+
40
+ # Get saved inputs (widget keys — managed by Streamlit)
41
+ src_sql = st.session_state.get("diff_src_sql", "")
42
+ tgt_sql = st.session_state.get("diff_tgt_sql", "")
43
+
44
+ # Render results if available
45
+ vm: SemanticDiffViewModel | None = state.diff_last_vm
46
+ if vm is not None:
47
+ selected_idx = state.selected_finding_index
48
+ _render_results(vm, src_sql, tgt_sql, selected_idx, theme)
49
+
50
+
51
+ def _render_header(theme: ColorTokens) -> None:
52
+ """Render the page header."""
53
+ st.markdown(
54
+ f"""
55
+ <div style="padding:12px 0; margin-bottom:16px; border-bottom:1px solid {theme.border};">
56
+ <div style="font-size:18px; font-weight:700; color:{theme.text_primary};">
57
+ Semantic Diff
58
+ </div>
59
+ <div style="font-size:12px; color:{theme.text_muted}; margin-top:2px;">
60
+ AST-based semantic equivalence analysis between two SQL statements
61
+ </div>
62
+ </div>
63
+ """,
64
+ unsafe_allow_html=True,
65
+ )
66
+
67
+
68
+ def _render_input_section(theme: ColorTokens) -> None:
69
+ """Render the SQL input section."""
70
+ col_src, col_tgt = st.columns(2)
71
+
72
+ with col_src:
73
+ st.markdown(
74
+ '<div style="font-size:11px; font-weight:600; letter-spacing:0.06em; '
75
+ 'text-transform:uppercase; color:' + theme.text_muted + '; margin-bottom:6px;">'
76
+ 'Source SQL</div>',
77
+ unsafe_allow_html=True,
78
+ )
79
+ st.text_area(
80
+ "Source",
81
+ key="diff_src_sql",
82
+ height=160,
83
+ label_visibility="collapsed",
84
+ placeholder="SELECT DATE_ADD(created_at, INTERVAL 7 DAY) FROM users",
85
+ )
86
+ st.selectbox(
87
+ "Dialect",
88
+ DIALECTS,
89
+ key="diff_src_dialect",
90
+ label_visibility="collapsed",
91
+ )
92
+
93
+ with col_tgt:
94
+ st.markdown(
95
+ '<div style="font-size:11px; font-weight:600; letter-spacing:0.06em; '
96
+ 'text-transform:uppercase; color:' + theme.text_muted + '; margin-bottom:6px;">'
97
+ 'Target SQL</div>',
98
+ unsafe_allow_html=True,
99
+ )
100
+ st.text_area(
101
+ "Target",
102
+ key="diff_tgt_sql",
103
+ height=160,
104
+ label_visibility="collapsed",
105
+ placeholder="SELECT created_at + INTERVAL '7 days' FROM users",
106
+ )
107
+ st.selectbox(
108
+ "Dialect",
109
+ DIALECTS,
110
+ key="diff_tgt_dialect",
111
+ label_visibility="collapsed",
112
+ )
113
+
114
+ # Compare button
115
+ col_btn, _ = st.columns([1, 5])
116
+ with col_btn:
117
+ if st.button("Compare", type="primary", key="diff_compare", use_container_width=True):
118
+ _run_comparison(theme)
119
+
120
+
121
+ def _run_comparison(theme: ColorTokens) -> None:
122
+ """Run semantic diff and store result in session state."""
123
+ state = SessionState.get()
124
+ src_sql = st.session_state.get("diff_src_sql", "")
125
+ tgt_sql = st.session_state.get("diff_tgt_sql", "")
126
+ src_dialect = st.session_state.get("diff_src_dialect", "postgres")
127
+ tgt_dialect = st.session_state.get("diff_tgt_dialect", "mysql")
128
+
129
+ if not src_sql.strip() or not tgt_sql.strip():
130
+ st.warning("Please enter both SQL statements.")
131
+ return
132
+
133
+ with st.spinner("Analyzing semantic equivalence..."):
134
+ backend_result = diff_sql_ast(src_sql, tgt_sql, src_dialect, tgt_dialect)
135
+
136
+ vm = SemanticDiffViewModel.from_backend(
137
+ source_sql=src_sql,
138
+ target_sql=tgt_sql,
139
+ source_dialect=src_dialect,
140
+ target_dialect=tgt_dialect,
141
+ diff=backend_result,
142
+ )
143
+ state.diff_last_vm = vm
144
+ state.clear_finding_selection()
145
+ st.rerun()
146
+
147
+
148
+ def _render_results(
149
+ vm: SemanticDiffViewModel,
150
+ source_sql: str,
151
+ target_sql: str,
152
+ selected_idx: int | None,
153
+ theme: ColorTokens,
154
+ ) -> None:
155
+ """Render the diff results with finding list and inspector."""
156
+ from frontend.ui.diff import render_semantic_diff_workspace
157
+ render_semantic_diff_workspace(vm, source_sql, target_sql, theme, selected_idx)
@@ -0,0 +1,100 @@
1
+ """Function Library page for SQL Dialect Master v2."""
2
+ from __future__ import annotations
3
+
4
+ import streamlit as st
5
+
6
+ from frontend.app_context_v2 import get_page_data
7
+ from frontend.core.design_tokens import ColorTokens
8
+ from frontend.core.escaping import esc
9
+
10
+ CATEGORY_ICONS = {
11
+ "string": "S",
12
+ "date": "D",
13
+ "math": "M",
14
+ "aggregate": "A",
15
+ "window": "W",
16
+ "conditional": "C",
17
+ "conversion": "V",
18
+ "json": "J",
19
+ "array": "L",
20
+ "system": "Y",
21
+ "geo": "G",
22
+ }
23
+
24
+
25
+ def render_functions_page(theme: ColorTokens) -> None:
26
+ """Render the Function Library page.
27
+
28
+ Data is injected by the page registry via ``get_page_data()`` — the
29
+ renderer keeps the unified PageRenderer contract of receiving only
30
+ the active theme.
31
+ """
32
+ data = get_page_data()
33
+ funcs_data = data.funcs_data
34
+
35
+ st.markdown(
36
+ f"""
37
+ <div style="padding:12px 0; margin-bottom:16px; border-bottom:1px solid {theme.border};">
38
+ <div style="font-size:18px; font-weight:700; color:{theme.text_primary};">
39
+ Function Library
40
+ </div>
41
+ <div style="font-size:12px; color:{theme.text_muted}; margin-top:2px;">
42
+ Search and compare SQL functions across dialects
43
+ </div>
44
+ </div>
45
+ """,
46
+ unsafe_allow_html=True,
47
+ )
48
+
49
+ all_funcs = funcs_data.get("functions", [])
50
+
51
+ # Search and filter
52
+ col_search, col_cat = st.columns([2, 1])
53
+ with col_search:
54
+ query = st.text_input("Search", key="func_search", placeholder="Type function name...", label_visibility="collapsed")
55
+ with col_cat:
56
+ categories = sorted({f.get("category", "other") for f in all_funcs})
57
+ cat_options = ["All"] + categories
58
+ selected_cat = st.selectbox("Category", cat_options, key="func_cat")
59
+
60
+ # Filter
61
+ funcs = all_funcs
62
+ if query:
63
+ q = query.lower()
64
+ funcs = [f for f in funcs if q in f.get("name", "").lower() or q in f.get("description", "").lower()]
65
+ if selected_cat != "All":
66
+ funcs = [f for f in funcs if f.get("category", "") == selected_cat]
67
+
68
+ st.caption(f"{len(funcs)} functions" + (f" matching '{esc(query)}'" if query else ""))
69
+
70
+ if not funcs:
71
+ st.info("No functions found.")
72
+ return
73
+
74
+ # Display as compact table
75
+ for f in funcs[:50]:
76
+ _render_function_row(f, theme)
77
+
78
+
79
+ def _render_function_row(func: dict, theme: ColorTokens) -> None:
80
+ """Render a single function row in the library table."""
81
+ name = esc(func.get("name", ""))
82
+ category = func.get("category", "other")
83
+ icon = CATEGORY_ICONS.get(category, "?")
84
+ description = esc(func.get("description", ""))
85
+ dialect_count = len(func.get("dialects", {}))
86
+
87
+ with st.expander(f"**{name}** · `{icon}` {esc(category.title())} · {dialect_count} dialects", expanded=False):
88
+ st.markdown(f"**{description}**")
89
+
90
+ dialects = func.get("dialects", {})
91
+ if dialects:
92
+ st.markdown("**Syntax by Database:**")
93
+ items = list(dialects.items())
94
+ for i in range(0, len(items), 3):
95
+ cols = st.columns(min(3, len(items) - i))
96
+ for j, (dialect, syntax) in enumerate(items[i:i+3]):
97
+ with cols[j]:
98
+ st.markdown(
99
+ f'**{esc(dialect.upper())}**\n`{esc(syntax)}`'
100
+ )
@@ -0,0 +1,144 @@
1
+ """History page for SQL Dialect Master v2."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import UTC, datetime
5
+
6
+ import streamlit as st
7
+
8
+ from frontend.app_context_v2 import copy_sql
9
+ from frontend.core.design_tokens import ColorTokens
10
+ from frontend.core.escaping import esc
11
+ from frontend.core.state import SessionState
12
+
13
+
14
+ def render_history_page(theme: ColorTokens) -> None:
15
+ """Render the History page."""
16
+ state = SessionState.get()
17
+
18
+ st.markdown(
19
+ f"""
20
+ <div style="padding:12px 0; margin-bottom:16px; border-bottom:1px solid {theme.border};">
21
+ <div style="font-size:18px; font-weight:700; color:{theme.text_primary};">
22
+ History
23
+ </div>
24
+ <div style="font-size:12px; color:{theme.text_muted}; margin-top:2px;">
25
+ Recent conversions and saved queries
26
+ </div>
27
+ </div>
28
+ """,
29
+ unsafe_allow_html=True,
30
+ )
31
+
32
+ history = state.history
33
+
34
+ if not history:
35
+ st.info("No conversion history yet.")
36
+ return
37
+
38
+ # Search and filter controls (widget keys — managed by Streamlit)
39
+ col_search, col_filter = st.columns([2, 1])
40
+ with col_search:
41
+ search = st.text_input("Search", key="hist_search", placeholder="Search SQL...", label_visibility="collapsed")
42
+ with col_filter:
43
+ filter_status = st.selectbox("Status", ["All", "Success", "Error"], key="hist_filter")
44
+
45
+ # Apply filters
46
+ filtered = history
47
+ if search:
48
+ s = search.lower()
49
+ filtered = [h for h in filtered if s in h.get("sql", "").lower()]
50
+ if filter_status == "Success":
51
+ filtered = [h for h in filtered if h.get("status") == "valid"]
52
+ elif filter_status == "Error":
53
+ filtered = [h for h in filtered if h.get("status") == "error"]
54
+
55
+ st.caption(f"{len(filtered)} entries")
56
+
57
+ # Render entries
58
+ for entry in filtered:
59
+ _render_history_entry(state, entry, theme)
60
+
61
+
62
+ def _render_history_entry(
63
+ state: SessionState,
64
+ entry: dict,
65
+ theme: ColorTokens,
66
+ ) -> None:
67
+ """Render a single history entry."""
68
+ src = esc(entry.get("src", "unknown"))
69
+ tgt = esc(entry.get("tgt", "unknown"))
70
+ sql_preview = esc(entry.get("sql", "")[:80])
71
+ if len(entry.get("sql", "")) > 80:
72
+ sql_preview += "..."
73
+ status = entry.get("status", "neutral")
74
+ created_at = entry.get("created_at", "")
75
+ identity = entry.get("identity", "")
76
+
77
+ # Status badge color
78
+ if status == "valid":
79
+ status_color = theme.success
80
+ status_text = "✓ Valid"
81
+ elif status == "error":
82
+ status_color = theme.danger
83
+ status_text = "✗ Error"
84
+ else:
85
+ status_color = theme.text_muted
86
+ status_text = "—"
87
+
88
+ # Time ago
89
+ time_str = _format_time_ago(created_at) if created_at else "recent"
90
+
91
+ with st.expander(f"{src} → {tgt} · {time_str}", expanded=False):
92
+ st.markdown(
93
+ f'<div style="display:flex; align-items:center; gap:8px; margin-bottom:8px;">'
94
+ f'<span style="color:{status_color}; font-size:11px; font-weight:600;">{status_text}</span>'
95
+ f'</div>',
96
+ unsafe_allow_html=True,
97
+ )
98
+ st.code(sql_preview, language="sql")
99
+
100
+ col_load, col_copy, col_fav, col_del = st.columns([1, 1, 1, 1])
101
+ with col_load:
102
+ if st.button("Load", key=f"hist_load_{identity}"):
103
+ from frontend.core.navigation import create_navigation_intent
104
+ intent = create_navigation_intent(
105
+ target_page="convert",
106
+ action="open_conversion",
107
+ sql=entry.get("sql", ""),
108
+ dialect=entry.get("src", "postgres"),
109
+ origin="history",
110
+ )
111
+ st.session_state.sdm_navigation_intent = intent
112
+ st.rerun()
113
+ with col_copy:
114
+ if st.button("Copy", key=f"hist_copy_{identity}"):
115
+ copy_sql(entry.get("sql", ""), key=f"hist_copy_sql_{identity}")
116
+ with col_fav:
117
+ if not state.is_favorite(identity):
118
+ if st.button("☆", key=f"hist_fav_{identity}"):
119
+ state.add_favorite(identity)
120
+ st.rerun()
121
+ else:
122
+ st.button("★", key=f"hist_fav_{identity}", disabled=True)
123
+ with col_del:
124
+ if st.button("Del", key=f"hist_del_{identity}"):
125
+ state.remove_history_entry(identity)
126
+ st.rerun()
127
+
128
+
129
+ def _format_time_ago(iso_str: str) -> str:
130
+ """Format an ISO timestamp as 'X minutes ago' etc."""
131
+ try:
132
+ dt = datetime.fromisoformat(iso_str)
133
+ delta = datetime.now(UTC) - dt
134
+ seconds = int(delta.total_seconds())
135
+ if seconds < 60:
136
+ return f"{seconds}s ago"
137
+ elif seconds < 3600:
138
+ return f"{seconds // 60}m ago"
139
+ elif seconds < 86400:
140
+ return f"{seconds // 3600}h ago"
141
+ else:
142
+ return f"{seconds // 86400}d ago"
143
+ except (ValueError, TypeError):
144
+ return "recent"