sql-dialect-master 1.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/__init__.py +1 -0
- backend/api/__init__.py +1 -0
- backend/api/main.py +732 -0
- backend/api/middleware.py +274 -0
- backend/api/rate_limit_store.py +105 -0
- backend/api/readiness.py +178 -0
- backend/core/__init__.py +57 -0
- backend/core/cache.py +261 -0
- backend/core/column_hint_validation.py +22 -0
- backend/core/config.py +546 -0
- backend/core/constants.py +84 -0
- backend/core/exceptions.py +124 -0
- backend/core/function_call_scanner.py +64 -0
- backend/core/function_semantics.py +148 -0
- backend/core/functions_db.json +601 -0
- backend/core/functions_lookup.py +332 -0
- backend/core/metadata.py +71 -0
- backend/core/nl2sql.py +85 -0
- backend/core/nl2sql_components/__init__.py +72 -0
- backend/core/nl2sql_components/aggregations.py +35 -0
- backend/core/nl2sql_components/boolean_conditions.py +279 -0
- backend/core/nl2sql_components/core_extract.py +106 -0
- backend/core/nl2sql_components/evidence.py +122 -0
- backend/core/nl2sql_components/mappings.py +185 -0
- backend/core/nl2sql_components/relations.py +180 -0
- backend/core/nl2sql_components/templates.py +130 -0
- backend/core/nl2sql_components/tokenizer.py +187 -0
- backend/core/nl2sql_legacy.py +22 -0
- backend/core/nl2sql_pipeline/__init__.py +13 -0
- backend/core/nl2sql_pipeline/aggregations.py +26 -0
- backend/core/nl2sql_pipeline/clause_extract.py +97 -0
- backend/core/nl2sql_pipeline/dialect.py +139 -0
- backend/core/nl2sql_pipeline/intent.py +85 -0
- backend/core/nl2sql_pipeline/ir.py +70 -0
- backend/core/nl2sql_pipeline/models.py +30 -0
- backend/core/nl2sql_pipeline/orchestrator.py +576 -0
- backend/core/nl2sql_pipeline/predicates.py +182 -0
- backend/core/nl2sql_pipeline/renderer.py +248 -0
- backend/core/nl2sql_pipeline/resolution.py +69 -0
- backend/core/nl2sql_pipeline/suggestions.py +30 -0
- backend/core/nl2sql_pipeline/templates.py +371 -0
- backend/core/nl2sql_pipeline/validation.py +28 -0
- backend/core/p1_sql_scanner.py +196 -0
- backend/core/p2_data_validation.py +72 -0
- backend/core/parser.py +280 -0
- backend/core/post_processor.py +129 -0
- backend/core/rules.py +808 -0
- backend/core/semantic_diff.py +385 -0
- backend/core/transpiler.py +791 -0
- backend/core/type_mapping.json +593 -0
- backend/core/type_mapping.py +765 -0
- backend/utils/__init__.py +2 -0
- backend/utils/validators.py +407 -0
- frontend/__init__.py +10 -0
- frontend/app_context_v2.py +201 -0
- frontend/core/__init__.py +33 -0
- frontend/core/design_tokens.py +127 -0
- frontend/core/escaping.py +13 -0
- frontend/core/navigation.py +135 -0
- frontend/core/state.py +612 -0
- frontend/core/styles.py +523 -0
- frontend/core/themes.py +69 -0
- frontend/core/viewmodels.py +499 -0
- frontend/pages/__init__.py +0 -0
- frontend/pages/convert.py +283 -0
- frontend/pages/diff.py +157 -0
- frontend/pages/functions.py +100 -0
- frontend/pages/history.py +144 -0
- frontend/pages/lineage.py +168 -0
- frontend/pages/nl2sql.py +305 -0
- frontend/pages/query_analysis.py +120 -0
- frontend/pages/runtime.py +66 -0
- frontend/pages/settings.py +60 -0
- frontend/pages/templates.py +67 -0
- frontend/pages/types.py +110 -0
- frontend/templates_v2.py +53 -0
- frontend/ui/__init__.py +0 -0
- frontend/ui/command_palette.py +153 -0
- frontend/ui/diff.py +275 -0
- frontend/ui/editor.py +102 -0
- frontend/ui/status.py +255 -0
- sql_dialect_master-1.1.1.dist-info/METADATA +360 -0
- sql_dialect_master-1.1.1.dist-info/RECORD +86 -0
- sql_dialect_master-1.1.1.dist-info/WHEEL +5 -0
- sql_dialect_master-1.1.1.dist-info/licenses/LICENSE +21 -0
- sql_dialect_master-1.1.1.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""Frontend ViewModels.
|
|
2
|
+
|
|
3
|
+
These adapters transform backend domain objects (TranspileResult,
|
|
4
|
+
SemanticDiff, NL2SQLResult, etc.) into presentation-friendly structures
|
|
5
|
+
that the UI layer consumes. The UI must never reference backend field
|
|
6
|
+
names directly — all access goes through the ViewModel.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
from backend.core.semantic_diff import SemanticDiff, StructuredSemanticDifference
|
|
14
|
+
|
|
15
|
+
# ── Conversion ViewModel ───────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
StatusKind = Literal["valid", "warning", "error", "info", "neutral"]
|
|
18
|
+
SyntaxStatus = Literal["ok", "error", "pending"]
|
|
19
|
+
ASTStatus = Literal["ok", "error", "pending"]
|
|
20
|
+
SemanticStatus = Literal["equivalent", "different", "unknown", "parse_error", "pending"]
|
|
21
|
+
RuntimeStatus = Literal["not_executed", "ok", "error", "pending"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ConversionViewModel:
|
|
26
|
+
"""Presentation model for a single SQL conversion result."""
|
|
27
|
+
|
|
28
|
+
status: StatusKind
|
|
29
|
+
source_dialect: str
|
|
30
|
+
target_dialect: str
|
|
31
|
+
source_sql: str
|
|
32
|
+
target_sql: str | None
|
|
33
|
+
syntax_status: SyntaxStatus
|
|
34
|
+
ast_status: ASTStatus
|
|
35
|
+
semantic_status: SemanticStatus
|
|
36
|
+
semantic_label: str = ""
|
|
37
|
+
runtime_status: RuntimeStatus = "not_executed"
|
|
38
|
+
target_validation_state: str = "target_valid"
|
|
39
|
+
warnings: list[str] = field(default_factory=list)
|
|
40
|
+
transformations: list[str] = field(default_factory=list)
|
|
41
|
+
compatibility_notes: list[str] = field(default_factory=list)
|
|
42
|
+
error_message: str | None = None
|
|
43
|
+
semantic_findings: list[StructuredSemanticDifference] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_transpile_result(cls, result: Any) -> ConversionViewModel:
|
|
47
|
+
"""Build a ViewModel from a backend TranspileResult."""
|
|
48
|
+
if result.success:
|
|
49
|
+
status: StatusKind = "valid"
|
|
50
|
+
error_message = None
|
|
51
|
+
semantic_status: SemanticStatus = "pending"
|
|
52
|
+
semantic_label = ""
|
|
53
|
+
findings: list[StructuredSemanticDifference] = []
|
|
54
|
+
else:
|
|
55
|
+
status = "error"
|
|
56
|
+
error_message = result.error or "Conversion failed"
|
|
57
|
+
semantic_status = "parse_error"
|
|
58
|
+
semantic_label = "Parse Error"
|
|
59
|
+
findings = []
|
|
60
|
+
|
|
61
|
+
return cls(
|
|
62
|
+
status=status,
|
|
63
|
+
source_dialect=result.source_dialect,
|
|
64
|
+
target_dialect=result.target_dialect,
|
|
65
|
+
source_sql=result.source_sql,
|
|
66
|
+
target_sql=result.target_sql,
|
|
67
|
+
syntax_status="ok" if result.success else "error",
|
|
68
|
+
ast_status="ok" if result.success else "error",
|
|
69
|
+
semantic_status=semantic_status,
|
|
70
|
+
semantic_label=semantic_label,
|
|
71
|
+
target_validation_state=getattr(result, "target_validation_state", "target_valid"),
|
|
72
|
+
warnings=list(result.warnings),
|
|
73
|
+
transformations=list(result.transformations),
|
|
74
|
+
compatibility_notes=list(result.compatibility_notes),
|
|
75
|
+
error_message=error_message,
|
|
76
|
+
semantic_findings=findings,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_semantic_diff(cls, base: ConversionViewModel, diff: SemanticDiff) -> ConversionViewModel:
|
|
81
|
+
"""Enrich a ConversionViewModel with semantic diff results."""
|
|
82
|
+
from frontend.core.design_tokens import (
|
|
83
|
+
SEMANTIC_STATUS_COLOR,
|
|
84
|
+
SEMANTIC_STATUS_LABELS,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
semantic_status_map: dict[str, SemanticStatus] = {
|
|
88
|
+
"equivalent": "equivalent",
|
|
89
|
+
"structurally_equivalent": "equivalent",
|
|
90
|
+
"different": "different",
|
|
91
|
+
"potentially_different": "unknown",
|
|
92
|
+
"definitely_different": "different",
|
|
93
|
+
"parse_error": "parse_error",
|
|
94
|
+
"unknown": "unknown",
|
|
95
|
+
}
|
|
96
|
+
new_semantic = semantic_status_map.get(diff.semantic_classification, "unknown")
|
|
97
|
+
label = SEMANTIC_STATUS_LABELS.get(diff.semantic_classification, diff.semantic_classification)
|
|
98
|
+
color_key = SEMANTIC_STATUS_COLOR.get(diff.semantic_classification, "neutral")
|
|
99
|
+
status: StatusKind = color_key # type: ignore[assignment]
|
|
100
|
+
|
|
101
|
+
enriched = cls(
|
|
102
|
+
status=status,
|
|
103
|
+
source_dialect=base.source_dialect,
|
|
104
|
+
target_dialect=base.target_dialect,
|
|
105
|
+
source_sql=base.source_sql,
|
|
106
|
+
target_sql=base.target_sql,
|
|
107
|
+
syntax_status=base.syntax_status,
|
|
108
|
+
ast_status=base.ast_status,
|
|
109
|
+
semantic_status=new_semantic,
|
|
110
|
+
semantic_label=label,
|
|
111
|
+
target_validation_state=base.target_validation_state,
|
|
112
|
+
warnings=base.warnings,
|
|
113
|
+
transformations=base.transformations,
|
|
114
|
+
compatibility_notes=base.compatibility_notes,
|
|
115
|
+
error_message=base.error_message,
|
|
116
|
+
semantic_findings=list(diff.structured_differences),
|
|
117
|
+
)
|
|
118
|
+
return enriched
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def success(self) -> bool:
|
|
123
|
+
"""Whether the conversion produced a usable result."""
|
|
124
|
+
return self.status == "valid"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ── Batch Conversion ViewModel ─────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class BatchResultItem:
|
|
131
|
+
statement_index: int
|
|
132
|
+
original_sql: str
|
|
133
|
+
converted_sql: str | None
|
|
134
|
+
success: bool
|
|
135
|
+
error: str | None = None
|
|
136
|
+
warnings: list[str] = field(default_factory=list)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class BatchConversionViewModel:
|
|
141
|
+
total: int
|
|
142
|
+
succeeded: int
|
|
143
|
+
failed: int
|
|
144
|
+
items: list[BatchResultItem] = field(default_factory=list)
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def success_rate(self) -> float:
|
|
148
|
+
return self.succeeded / self.total if self.total else 0.0
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def from_results(cls, statements: list[str], results: list[Any]) -> BatchConversionViewModel:
|
|
152
|
+
items = []
|
|
153
|
+
succeeded = 0
|
|
154
|
+
for i, r in enumerate(results):
|
|
155
|
+
ok = getattr(r, "success", False)
|
|
156
|
+
if ok:
|
|
157
|
+
succeeded += 1
|
|
158
|
+
items.append(BatchResultItem(
|
|
159
|
+
statement_index=i + 1,
|
|
160
|
+
original_sql=statements[i] if i < len(statements) else "",
|
|
161
|
+
converted_sql=r.target_sql if ok else None,
|
|
162
|
+
success=ok,
|
|
163
|
+
error=r.error if not ok else None,
|
|
164
|
+
warnings=list(r.warnings) if ok else [],
|
|
165
|
+
))
|
|
166
|
+
return cls(
|
|
167
|
+
total=len(results),
|
|
168
|
+
succeeded=succeeded,
|
|
169
|
+
failed=len(results) - succeeded,
|
|
170
|
+
items=items,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── NL2SQL ViewModel ───────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
ConfidenceLevel = Literal["high", "review", "low"]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class NL2SQLViewModel:
|
|
181
|
+
success: bool
|
|
182
|
+
input_text: str
|
|
183
|
+
sql: str | None
|
|
184
|
+
dialect: str
|
|
185
|
+
explanation: str
|
|
186
|
+
confidence: float
|
|
187
|
+
suggestions: list[str]
|
|
188
|
+
parsed_elements: dict[str, Any]
|
|
189
|
+
# Structured evidence trail backing the confidence score.
|
|
190
|
+
# Each entry is a dict with keys: evidence_id, label, weight, detail.
|
|
191
|
+
evidence_items: list[dict[str, Any]] = field(default_factory=list)
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def status(self) -> StatusKind:
|
|
195
|
+
return "valid" if self.success else "error"
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def confidence_level(self) -> ConfidenceLevel:
|
|
199
|
+
"""Map overall confidence to a display level."""
|
|
200
|
+
if self.confidence >= 0.8:
|
|
201
|
+
return "high"
|
|
202
|
+
elif self.confidence >= 0.5:
|
|
203
|
+
return "review"
|
|
204
|
+
return "low"
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def confidence_label(self) -> str:
|
|
208
|
+
return {
|
|
209
|
+
"high": "High confidence",
|
|
210
|
+
"review": "Review recommended",
|
|
211
|
+
"low": "Low confidence",
|
|
212
|
+
}[self.confidence_level]
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def evidence_summary(self) -> str:
|
|
216
|
+
"""Human-readable one-line summary of the evidence trail."""
|
|
217
|
+
if not self.evidence_items:
|
|
218
|
+
return ""
|
|
219
|
+
parts = [item["label"] for item in self.evidence_items if item.get("weight", 0.0) != 0.0]
|
|
220
|
+
return "; ".join(parts) if parts else ""
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def tables(self) -> list[str]:
|
|
224
|
+
"""Extract table names from parsed_elements."""
|
|
225
|
+
pe = self.parsed_elements
|
|
226
|
+
return list({t for t in pe.get("tables", []) if t})
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def columns(self) -> list[str]:
|
|
230
|
+
"""Extract column names from parsed_elements."""
|
|
231
|
+
pe = self.parsed_elements
|
|
232
|
+
return list({c for c in pe.get("columns", []) if c})
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def values(self) -> list[str]:
|
|
236
|
+
"""Extract numeric/string values from parsed_elements."""
|
|
237
|
+
pe = self.parsed_elements
|
|
238
|
+
return list({str(v) for v in pe.get("numbers", []) if v})
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def is_chinese(self) -> bool:
|
|
242
|
+
return bool(self.parsed_elements.get("is_chinese"))
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def token_count(self) -> int:
|
|
246
|
+
return int(self.parsed_elements.get("token_count", 0))
|
|
247
|
+
|
|
248
|
+
@classmethod
|
|
249
|
+
def from_nl2sql_result(cls, result: Any) -> NL2SQLViewModel:
|
|
250
|
+
evidence = getattr(result, "evidence", None)
|
|
251
|
+
evidence_items = []
|
|
252
|
+
if evidence is not None:
|
|
253
|
+
evidence_items = [
|
|
254
|
+
{
|
|
255
|
+
"evidence_id": item.evidence_id,
|
|
256
|
+
"label": item.label,
|
|
257
|
+
"weight": item.weight,
|
|
258
|
+
"detail": item.detail,
|
|
259
|
+
}
|
|
260
|
+
for item in evidence.items
|
|
261
|
+
]
|
|
262
|
+
return cls(
|
|
263
|
+
success=result.success,
|
|
264
|
+
input_text=result.input_text,
|
|
265
|
+
sql=result.sql,
|
|
266
|
+
dialect=result.dialect,
|
|
267
|
+
explanation=result.explanation,
|
|
268
|
+
confidence=result.confidence,
|
|
269
|
+
suggestions=list(result.suggestions),
|
|
270
|
+
parsed_elements=dict(result.parsed_elements),
|
|
271
|
+
evidence_items=evidence_items,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def clean_sql(self) -> str:
|
|
275
|
+
"""Return SQL without the backend's specific comment prefix.
|
|
276
|
+
|
|
277
|
+
The backend prefixes generated SQL with '-- Generated for DIALECT\\n'.
|
|
278
|
+
We only strip that exact prefix to avoid removing legitimate comments.
|
|
279
|
+
"""
|
|
280
|
+
if not self.sql:
|
|
281
|
+
return ""
|
|
282
|
+
lines = self.sql.splitlines()
|
|
283
|
+
# Remove only the backend-generated header comment
|
|
284
|
+
if lines and lines[0].startswith("-- Generated for "):
|
|
285
|
+
lines = lines[1:]
|
|
286
|
+
return "\n".join(lines).strip() or self.sql
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ── Semantic Diff ViewModel ────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
FindingSeverity = Literal["error", "warning"]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@dataclass
|
|
295
|
+
class SemanticFindingViewModel:
|
|
296
|
+
"""Presentation model for one structured semantic difference finding."""
|
|
297
|
+
|
|
298
|
+
index: int
|
|
299
|
+
category: str
|
|
300
|
+
severity: FindingSeverity
|
|
301
|
+
source_fragment: str
|
|
302
|
+
target_fragment: str
|
|
303
|
+
explanation: str
|
|
304
|
+
confidence: float
|
|
305
|
+
evidence: str | None
|
|
306
|
+
|
|
307
|
+
@classmethod
|
|
308
|
+
def from_backend(cls, index: int, diff: StructuredSemanticDifference) -> SemanticFindingViewModel:
|
|
309
|
+
return cls(
|
|
310
|
+
index=index,
|
|
311
|
+
category=diff.category,
|
|
312
|
+
severity=diff.severity, # type: ignore[arg-type]
|
|
313
|
+
source_fragment=diff.source_fragment or "",
|
|
314
|
+
target_fragment=diff.target_fragment or "",
|
|
315
|
+
explanation=diff.explanation or "",
|
|
316
|
+
confidence=diff.confidence,
|
|
317
|
+
evidence=diff.evidence,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
@property
|
|
321
|
+
def severity_label(self) -> str:
|
|
322
|
+
return "HIGH" if self.severity == "error" else "MEDIUM"
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def category_label(self) -> str:
|
|
326
|
+
return self.category.replace("_", " ").title()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
SemanticClassification = Literal[
|
|
330
|
+
"equivalent",
|
|
331
|
+
"structurally_equivalent",
|
|
332
|
+
"potentially_different",
|
|
333
|
+
"definitely_different",
|
|
334
|
+
"unknown",
|
|
335
|
+
"parse_error",
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@dataclass
|
|
340
|
+
class SemanticDiffViewModel:
|
|
341
|
+
"""Presentation model for a full semantic diff comparison."""
|
|
342
|
+
|
|
343
|
+
classification: SemanticClassification
|
|
344
|
+
classification_label: str
|
|
345
|
+
overall_status: StatusKind
|
|
346
|
+
confidence: float
|
|
347
|
+
source_sql: str
|
|
348
|
+
target_sql: str
|
|
349
|
+
source_dialect: str
|
|
350
|
+
target_dialect: str
|
|
351
|
+
parse_error: str | None
|
|
352
|
+
differences: list[str]
|
|
353
|
+
findings: list[SemanticFindingViewModel]
|
|
354
|
+
evidence: str | None
|
|
355
|
+
|
|
356
|
+
@classmethod
|
|
357
|
+
def from_backend(
|
|
358
|
+
cls,
|
|
359
|
+
source_sql: str,
|
|
360
|
+
target_sql: str,
|
|
361
|
+
source_dialect: str,
|
|
362
|
+
target_dialect: str,
|
|
363
|
+
diff: SemanticDiff,
|
|
364
|
+
) -> SemanticDiffViewModel:
|
|
365
|
+
from frontend.core.design_tokens import (
|
|
366
|
+
SEMANTIC_STATUS_COLOR,
|
|
367
|
+
SEMANTIC_STATUS_LABELS,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
label = SEMANTIC_STATUS_LABELS.get(diff.semantic_classification, diff.semantic_classification)
|
|
371
|
+
color_key = SEMANTIC_STATUS_COLOR.get(diff.semantic_classification, "neutral")
|
|
372
|
+
status: StatusKind = color_key # type: ignore[assignment]
|
|
373
|
+
|
|
374
|
+
findings = [
|
|
375
|
+
SemanticFindingViewModel.from_backend(i, sd)
|
|
376
|
+
for i, sd in enumerate(diff.structured_differences)
|
|
377
|
+
]
|
|
378
|
+
|
|
379
|
+
return cls(
|
|
380
|
+
classification=diff.semantic_classification, # type: ignore[arg-type]
|
|
381
|
+
classification_label=label,
|
|
382
|
+
overall_status=status,
|
|
383
|
+
confidence=diff.confidence,
|
|
384
|
+
source_sql=source_sql,
|
|
385
|
+
target_sql=target_sql,
|
|
386
|
+
source_dialect=source_dialect,
|
|
387
|
+
target_dialect=target_dialect,
|
|
388
|
+
parse_error=diff.parse_error,
|
|
389
|
+
differences=list(diff.differences),
|
|
390
|
+
findings=findings,
|
|
391
|
+
evidence=diff.evidence,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
@property
|
|
395
|
+
def finding_count(self) -> int:
|
|
396
|
+
return len(self.findings)
|
|
397
|
+
|
|
398
|
+
@property
|
|
399
|
+
def error_count(self) -> int:
|
|
400
|
+
return sum(1 for f in self.findings if f.severity == "error")
|
|
401
|
+
|
|
402
|
+
@property
|
|
403
|
+
def warning_count(self) -> int:
|
|
404
|
+
return sum(1 for f in self.findings if f.severity == "warning")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# ── Lineage ViewModel ──────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
@dataclass
|
|
410
|
+
class LineageTable:
|
|
411
|
+
name: str
|
|
412
|
+
alias: str | None = None
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@dataclass
|
|
416
|
+
class LineageOutputColumn:
|
|
417
|
+
name: str
|
|
418
|
+
expression: str = ""
|
|
419
|
+
source_table: str | None = None
|
|
420
|
+
source_column: str | None = None
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@dataclass
|
|
424
|
+
class LineageViewModel:
|
|
425
|
+
dialect: str
|
|
426
|
+
sql: str
|
|
427
|
+
tables: list[LineageTable] = field(default_factory=list)
|
|
428
|
+
output_columns: list[LineageOutputColumn] = field(default_factory=list)
|
|
429
|
+
column_references: dict[str, list[str]] = field(default_factory=dict)
|
|
430
|
+
mermaid_safe: str = ""
|
|
431
|
+
|
|
432
|
+
@classmethod
|
|
433
|
+
def empty(cls, dialect: str, sql: str) -> LineageViewModel:
|
|
434
|
+
return cls(dialect=dialect, sql=sql)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ── Query Analysis ViewModel ───────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
@dataclass
|
|
440
|
+
class QueryAnalysisResult:
|
|
441
|
+
dialect: str
|
|
442
|
+
query_type: str = "SELECT"
|
|
443
|
+
tables: list[str] = field(default_factory=list)
|
|
444
|
+
join_count: int = 0
|
|
445
|
+
has_aggregation: bool = False
|
|
446
|
+
has_sorting: bool = False
|
|
447
|
+
limit: str | None = None
|
|
448
|
+
hints: list[str] = field(default_factory=list)
|
|
449
|
+
error: str | None = None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ── Helper: sanitize Mermaid identifiers ──────────────────────────────
|
|
453
|
+
|
|
454
|
+
_MERMAID_SAFE_REPLACEMENTS = {
|
|
455
|
+
'"': "",
|
|
456
|
+
"'": "",
|
|
457
|
+
"`": "",
|
|
458
|
+
"\\": "",
|
|
459
|
+
"/": "_",
|
|
460
|
+
" ": "_",
|
|
461
|
+
"-": "_",
|
|
462
|
+
".": "_",
|
|
463
|
+
",": "",
|
|
464
|
+
"(": "",
|
|
465
|
+
")": "",
|
|
466
|
+
"[": "",
|
|
467
|
+
"]": "",
|
|
468
|
+
"{": "",
|
|
469
|
+
"}": "",
|
|
470
|
+
"<": "",
|
|
471
|
+
">": "",
|
|
472
|
+
"|": "_",
|
|
473
|
+
"\n": "",
|
|
474
|
+
"\r": "",
|
|
475
|
+
"\t": "",
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def sanitize_mermaid_label(text: str) -> str:
|
|
480
|
+
"""Sanitize a string for use as a Mermaid node label or identifier."""
|
|
481
|
+
result = str(text)
|
|
482
|
+
for bad, repl in _MERMAID_SAFE_REPLACEMENTS.items():
|
|
483
|
+
result = result.replace(bad, repl)
|
|
484
|
+
# Collapse underscores
|
|
485
|
+
while "__" in result:
|
|
486
|
+
result = result.replace("__", "_")
|
|
487
|
+
result = result.strip("_")
|
|
488
|
+
return result or "node"
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def sanitize_mermaid_id(text: str) -> str:
|
|
492
|
+
"""Sanitize a string for use as a Mermaid node ID (alphanumeric + underscore)."""
|
|
493
|
+
import re
|
|
494
|
+
result = sanitize_mermaid_label(text)
|
|
495
|
+
result = re.sub(r"[^a-zA-Z0-9_]", "_", result)
|
|
496
|
+
# Must start with a letter
|
|
497
|
+
if result and not result[0].isalpha():
|
|
498
|
+
result = "n_" + result
|
|
499
|
+
return result or "node"
|
|
File without changes
|