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,274 @@
1
+ #!/usr/bin/env python3
2
+ """Middleware for SQL Dialect Master API.
3
+
4
+ Provides rate limiting, request-size protection, logging, and security headers.
5
+ """
6
+ import json
7
+ import logging
8
+ import time
9
+ import uuid
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from typing import Dict
13
+
14
+ from fastapi import Request, Response
15
+ from fastapi.responses import JSONResponse
16
+ from starlette.middleware.base import BaseHTTPMiddleware
17
+
18
+ from backend.core.exceptions import ErrorCode
19
+
20
+ from .rate_limit_store import RateLimitStore, create_rate_limit_store
21
+ from .readiness import health_probe_response
22
+
23
+ logger = logging.getLogger(__name__)
24
+ DEFAULT_MAX_REQUEST_BODY_BYTES = 512 * 1024
25
+
26
+
27
+ def _sanitize_log_value(value: str, max_length: int = 500) -> str:
28
+ """Prevent control characters from forging or corrupting log records."""
29
+ return value.replace("\r", "\\r").replace("\n", "\\n")[:max_length]
30
+
31
+
32
+ @dataclass
33
+ class RateLimitEntry:
34
+ """Legacy compatibility entry."""
35
+ requests: int = 0
36
+ window_start: float = field(default_factory=time.time)
37
+
38
+
39
+ class RateLimiter:
40
+ """Rate limiter backed by a local or shared store."""
41
+
42
+ def __init__(self, requests_per_window: int = 100, window_seconds: int = 60, store: RateLimitStore = None):
43
+ if requests_per_window <= 0:
44
+ raise ValueError("requests_per_window must be positive")
45
+ if window_seconds <= 0:
46
+ raise ValueError("window_seconds must be positive")
47
+ self._requests_per_window = requests_per_window
48
+ self._window_seconds = window_seconds
49
+ self._store = store or create_rate_limit_store()
50
+
51
+ @property
52
+ def _limits(self):
53
+ """Read-only compatibility view for the in-memory backend."""
54
+ return getattr(self._store, "_entries", {})
55
+
56
+ def is_allowed(self, client_id: str) -> tuple:
57
+ return self._store.check(client_id, self._requests_per_window, self._window_seconds)
58
+
59
+ def get_stats(self) -> Dict:
60
+ stats = self._store.stats()
61
+ stats.update({"requests_per_window": self._requests_per_window, "window_seconds": self._window_seconds})
62
+ return stats
63
+
64
+
65
+ class RateLimitMiddleware(BaseHTTPMiddleware):
66
+ """Rate limiting middleware with a bounded request-body guard."""
67
+
68
+ def __init__(self, app, limiter: RateLimiter = None, enabled: bool = True, max_body_bytes: int = DEFAULT_MAX_REQUEST_BODY_BYTES):
69
+ super().__init__(app)
70
+ if max_body_bytes <= 0:
71
+ raise ValueError("max_body_bytes must be positive")
72
+ self.limiter = limiter or RateLimiter()
73
+ self.enabled = enabled
74
+ self.max_body_bytes = max_body_bytes
75
+
76
+ async def _read_bounded_body(self, request: Request) -> Response | None:
77
+ """Consume and cache request bodies without trusting Content-Length."""
78
+ if request.method in {"GET", "HEAD"}:
79
+ return None
80
+ content_length = request.headers.get("content-length")
81
+ if content_length:
82
+ try:
83
+ declared_length = int(content_length)
84
+ except ValueError:
85
+ return JSONResponse(status_code=400, content={"success": False, "error": {"code": 400, "message": "Invalid Content-Length"}, "timestamp": datetime.now().isoformat()})
86
+ if declared_length > self.max_body_bytes:
87
+ return JSONResponse(status_code=413, content={"success": False, "error": {"code": 413, "message": "Request body exceeds the maximum allowed size", "max_bytes": self.max_body_bytes}, "timestamp": datetime.now().isoformat()})
88
+
89
+ total = 0
90
+ chunks = []
91
+ async for chunk in request.stream():
92
+ total += len(chunk)
93
+ if total > self.max_body_bytes:
94
+ return JSONResponse(status_code=413, content={"success": False, "error": {"code": 413, "message": "Request body exceeds the maximum allowed size", "max_bytes": self.max_body_bytes}, "timestamp": datetime.now().isoformat()})
95
+ chunks.append(chunk)
96
+ request._body = b"".join(chunks)
97
+ return None
98
+
99
+ async def dispatch(self, request: Request, call_next) -> Response:
100
+ body_error = await self._read_bounded_body(request)
101
+ if body_error is not None:
102
+ return body_error
103
+
104
+ if request.url.path == "/api/nl2sql" and request.method == "POST":
105
+ try:
106
+ body = await request.body()
107
+ payload = json.loads(body.decode("utf-8")) if body else {}
108
+ except (UnicodeDecodeError, json.JSONDecodeError):
109
+ payload = None
110
+ if isinstance(payload, dict):
111
+ from backend.core.column_hint_validation import validate_column_hints
112
+ error = validate_column_hints(payload.get("column_hints"))
113
+ if error:
114
+ return JSONResponse(
115
+ status_code=422,
116
+ content={"success": False, "error": {"code": "VALIDATION_FAILED", "message": error}},
117
+ )
118
+
119
+ if not self.enabled:
120
+ return await call_next(request)
121
+ if request.url.path in {"/health", "/", "/ready", "/health/deep"} and request.method in {"GET", "HEAD"}:
122
+ if request.url.path in {"/ready", "/health/deep"}:
123
+ return await health_probe_response(request)
124
+ return await call_next(request)
125
+
126
+ client_id = self._get_client_id(request)
127
+ is_allowed, remaining, reset_time = self.limiter.is_allowed(client_id)
128
+ if not is_allowed:
129
+ logger.warning("Rate limit exceeded for client: %s", _sanitize_log_value(client_id))
130
+ return JSONResponse(
131
+ status_code=429,
132
+ content={"success": False, "error": {"code": 429, "message": "Rate limit exceeded", "retry_after": reset_time}, "timestamp": datetime.now().isoformat()},
133
+ headers={"X-RateLimit-Limit": str(self.limiter._requests_per_window), "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": str(reset_time), "Retry-After": str(reset_time)},
134
+ )
135
+ response = await call_next(request)
136
+ response.headers["X-RateLimit-Limit"] = str(self.limiter._requests_per_window)
137
+ response.headers["X-RateLimit-Remaining"] = str(remaining)
138
+ response.headers["X-RateLimit-Reset"] = str(reset_time)
139
+ return response
140
+
141
+ def _get_client_id(self, request: Request) -> str:
142
+ return request.client.host if request.client else "unknown"
143
+
144
+
145
+ class StructuredLoggingMiddleware(BaseHTTPMiddleware):
146
+ """Structured request logging and request-id propagation."""
147
+
148
+ def __init__(self, app, log_body: bool = False):
149
+ super().__init__(app)
150
+ self.log_body = log_body
151
+
152
+ @staticmethod
153
+ def _get_request_id(request: Request) -> str:
154
+ """Reuse a valid RFC 4122 request ID; otherwise issue a fresh UUID."""
155
+ candidate = request.headers.get("X-Request-ID")
156
+ if candidate:
157
+ try:
158
+ parsed = uuid.UUID(candidate)
159
+ if parsed.variant == uuid.RFC_4122:
160
+ return candidate
161
+ except (ValueError, AttributeError, TypeError):
162
+ pass
163
+ return str(uuid.uuid4())
164
+
165
+ @staticmethod
166
+ async def _normalize_error_response(response: Response, request_id: str) -> Response:
167
+ """Normalize standard HTTP 4xx JSON responses while preserving domain-specific codes."""
168
+ code_by_status = {
169
+ 400: ErrorCode.BAD_REQUEST.value,
170
+ 404: ErrorCode.NOT_FOUND.value,
171
+ 405: ErrorCode.METHOD_NOT_ALLOWED.value,
172
+ 413: ErrorCode.PAYLOAD_TOO_LARGE.value,
173
+ 422: ErrorCode.VALIDATION_FAILED.value,
174
+ 429: ErrorCode.RATE_LIMITED.value,
175
+ }
176
+ if response.status_code not in code_by_status:
177
+ return response
178
+
179
+ try:
180
+ if hasattr(response, "body"):
181
+ raw_body = response.body
182
+ else:
183
+ chunks = []
184
+ async for chunk in response.body_iterator:
185
+ chunks.append(chunk)
186
+ raw_body = b"".join(chunks)
187
+ payload = json.loads(raw_body.decode("utf-8"))
188
+ except (AttributeError, UnicodeDecodeError, json.JSONDecodeError):
189
+ return response
190
+
191
+ error = payload.get("error") if isinstance(payload, dict) else None
192
+ expected_code = code_by_status[response.status_code]
193
+ if not isinstance(error, dict):
194
+ detail = payload.get("detail") if isinstance(payload, dict) else None
195
+ if response.status_code == 422:
196
+ error = {
197
+ "code": expected_code,
198
+ "message": "Request validation failed",
199
+ "details": detail if isinstance(detail, list) else ([detail] if detail else []),
200
+ }
201
+ elif response.status_code == 413:
202
+ error = {
203
+ "code": expected_code,
204
+ "message": str(detail or "Request body exceeds the maximum allowed size"),
205
+ "details": {},
206
+ }
207
+ elif response.status_code == 429:
208
+ error = {
209
+ "code": expected_code,
210
+ "message": str(detail or "Rate limit exceeded"),
211
+ "details": {},
212
+ }
213
+ elif response.status_code == 405:
214
+ error = {
215
+ "code": expected_code,
216
+ "message": str(detail or "Method not allowed"),
217
+ "details": {},
218
+ }
219
+ else:
220
+ error = {
221
+ "code": expected_code,
222
+ "message": str(detail or ("Resource not found" if response.status_code == 404 else "Bad request")),
223
+ "details": {},
224
+ }
225
+ else:
226
+ code = error.get("code")
227
+ if not isinstance(code, str):
228
+ error["code"] = expected_code
229
+ error.setdefault("details", {})
230
+
231
+ normalized = {
232
+ "success": False,
233
+ "error": error,
234
+ "timestamp": payload.get("timestamp", datetime.now().isoformat()) if isinstance(payload, dict) else datetime.now().isoformat(),
235
+ "request_id": request_id,
236
+ }
237
+ headers = dict(response.headers)
238
+ headers.pop("content-length", None)
239
+ headers.pop("content-type", None)
240
+ return JSONResponse(status_code=response.status_code, content=normalized, headers=headers)
241
+
242
+ async def dispatch(self, request: Request, call_next) -> Response:
243
+ start_time = time.time()
244
+ request_id = self._get_request_id(request)
245
+ request.state.request_id = request_id
246
+ status_code = 500
247
+ error = None
248
+ log_data = {"event": "request_start", "request_id": request_id, "method": _sanitize_log_value(request.method), "path": _sanitize_log_value(request.url.path), "query": _sanitize_log_value(str(request.query_params)), "client": _sanitize_log_value(request.client.host if request.client else "unknown"), "timestamp": datetime.now().isoformat()}
249
+ logger.info(json.dumps(log_data))
250
+ try:
251
+ response = await call_next(request)
252
+ response = await self._normalize_error_response(response, request_id)
253
+ status_code = response.status_code
254
+ except Exception as exc:
255
+ error = _sanitize_log_value(str(exc))
256
+ raise
257
+ finally:
258
+ process_time = time.time() - start_time
259
+ log_data = {"event": "request_end", "request_id": request_id, "method": _sanitize_log_value(request.method), "path": _sanitize_log_value(request.url.path), "status_code": status_code, "process_time_ms": round(process_time * 1000, 2), "error": error, "timestamp": datetime.now().isoformat()}
260
+ (logger.warning if status_code >= 400 else logger.info)(json.dumps(log_data))
261
+ response.headers["X-Request-ID"] = request_id
262
+ return response
263
+
264
+
265
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
266
+ """Add security headers to responses."""
267
+
268
+ async def dispatch(self, request: Request, call_next) -> Response:
269
+ response = await call_next(request)
270
+ response.headers["X-Content-Type-Options"] = "nosniff"
271
+ response.headers["X-Frame-Options"] = "DENY"
272
+ response.headers["X-XSS-Protection"] = "1; mode=block"
273
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
274
+ return response
@@ -0,0 +1,105 @@
1
+ """Shared rate-limit backends.
2
+
3
+ Redis is opt-in. The in-memory backend remains the default for local/single-process use.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import time
10
+ from threading import Lock
11
+ from typing import Protocol
12
+
13
+
14
+ class RateLimitStore(Protocol):
15
+ """Minimal storage contract used by RateLimiter."""
16
+
17
+ def check(self, key: str, limit: int, window_seconds: int) -> tuple[bool, int, int]:
18
+ """Return allowed, remaining, and reset seconds."""
19
+
20
+ def stats(self) -> dict:
21
+ """Return storage metadata."""
22
+
23
+
24
+ class InMemoryRateLimitStore:
25
+ """Thread-safe fixed-window store for a single process."""
26
+
27
+ def __init__(self) -> None:
28
+ self._entries: dict[str, tuple[int, float]] = {}
29
+ self._expirations: dict[str, float] = {}
30
+ self._next_cleanup_at = 0.0
31
+ self._lock = Lock()
32
+
33
+ def _prune_expired(self, now: float) -> None:
34
+ if now < self._next_cleanup_at:
35
+ return
36
+ expired_keys = [
37
+ key for key, expires_at in self._expirations.items() if expires_at <= now
38
+ ]
39
+ for key in expired_keys:
40
+ self._entries.pop(key, None)
41
+ self._expirations.pop(key, None)
42
+ self._next_cleanup_at = min(self._expirations.values(), default=float("inf"))
43
+
44
+ def check(self, key: str, limit: int, window_seconds: int) -> tuple[bool, int, int]:
45
+ with self._lock:
46
+ now = time.time()
47
+ self._prune_expired(now)
48
+ count, window_start = self._entries.get(key, (0, now))
49
+ if now - window_start >= window_seconds:
50
+ count, window_start = 0, now
51
+ count += 1
52
+ expires_at = window_start + window_seconds
53
+ self._entries[key] = (count, window_start)
54
+ self._expirations[key] = expires_at
55
+ self._next_cleanup_at = min(self._next_cleanup_at, expires_at)
56
+ return count <= limit, max(0, limit - count), max(0, int(expires_at - now))
57
+
58
+ def stats(self) -> dict:
59
+ with self._lock:
60
+ self._prune_expired(time.time())
61
+ return {"backend": "memory", "active_clients": len(self._entries)}
62
+
63
+
64
+ class RedisRateLimitStore:
65
+ """Atomic fixed-window store backed by Redis for multi-worker deployments."""
66
+
67
+ _SCRIPT = """
68
+ local count = redis.call('INCR', KEYS[1])
69
+ if count == 1 then
70
+ redis.call('EXPIRE', KEYS[1], ARGV[1])
71
+ end
72
+ local ttl = redis.call('TTL', KEYS[1])
73
+ return {count, ttl}
74
+ """
75
+
76
+ def __init__(self, url: str, namespace: str = "sdm:rate-limit:") -> None:
77
+ if not url:
78
+ raise ValueError("Redis URL is required when rate-limit backend is redis")
79
+ try:
80
+ import redis
81
+ except ImportError as exc:
82
+ raise RuntimeError("Redis rate limiting requires the optional 'redis' package") from exc
83
+ self._client = redis.Redis.from_url(url, decode_responses=False)
84
+ self._client.ping()
85
+ self._script = self._client.register_script(self._SCRIPT)
86
+ self._namespace = namespace
87
+
88
+ def check(self, key: str, limit: int, window_seconds: int) -> tuple[bool, int, int]:
89
+ values = self._script(keys=[f"{self._namespace}{key}"], args=[window_seconds])
90
+ count = int(values[0])
91
+ ttl = max(0, int(values[1]))
92
+ return count <= limit, max(0, limit - count), ttl
93
+
94
+ def stats(self) -> dict:
95
+ return {"backend": "redis", "namespace": self._namespace}
96
+
97
+
98
+ def create_rate_limit_store() -> RateLimitStore:
99
+ """Create the configured store; never silently downgrade Redis to memory."""
100
+ backend = os.getenv("SDM_RATE_LIMIT_BACKEND", "memory").strip().lower()
101
+ if backend == "memory":
102
+ return InMemoryRateLimitStore()
103
+ if backend == "redis":
104
+ return RedisRateLimitStore(os.getenv("SDM_REDIS_URL", ""))
105
+ raise ValueError("Unsupported SDM_RATE_LIMIT_BACKEND; expected 'memory' or 'redis'")
@@ -0,0 +1,178 @@
1
+ """Dependency-backed readiness probe for the API service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import secrets
9
+ import time
10
+ import weakref
11
+ from datetime import datetime
12
+ from typing import Any
13
+
14
+ from fastapi import Request
15
+ from fastapi.responses import JSONResponse
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _READINESS_CACHE_TTL_SECONDS = 5.0
20
+ _READINESS_TIMEOUT_SECONDS = 3.0
21
+ _READINESS_LOCKS: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = weakref.WeakKeyDictionary()
22
+ _READINESS_LOCKS_GUARD = __import__("threading").Lock()
23
+ _READINESS_CACHE = {"checks": None, "cached_at": 0.0}
24
+ _HEALTH_PROBE_PATHS = {"/ready", "/health/deep"}
25
+
26
+
27
+ def _probe_allowed(request: Request) -> bool:
28
+ """Allow health probes only from loopback or with the configured probe secret."""
29
+ token = os.getenv("SDM_HEALTH_PROBE_TOKEN", "").strip()
30
+ supplied = request.headers.get("X-Health-Probe-Token", "")
31
+ if token and supplied and secrets.compare_digest(supplied, token):
32
+ return True
33
+ client = request.client
34
+ return bool(client and client.host in {"127.0.0.1", "::1", "localhost"})
35
+
36
+
37
+ def _health_probe_denied_response() -> JSONResponse:
38
+ """Return a stable denial response for public health probe requests."""
39
+ return JSONResponse(
40
+ status_code=403,
41
+ content={
42
+ "success": False,
43
+ "error": {"code": 403, "message": "health probe access denied"},
44
+ },
45
+ )
46
+
47
+
48
+ async def health_probe_response(request: Request) -> JSONResponse:
49
+ """Authorize and dispatch a readiness/deep-health probe request."""
50
+ if request.url.path not in _HEALTH_PROBE_PATHS or request.method not in {"GET", "HEAD"}:
51
+ raise ValueError("health_probe_response called for a non-probe request")
52
+ if not _probe_allowed(request):
53
+ return _health_probe_denied_response()
54
+ if request.url.path == "/ready":
55
+ return await readiness_response(request)
56
+ return await deep_health_response(request)
57
+
58
+
59
+ def _get_readiness_lock() -> asyncio.Lock:
60
+ loop = asyncio.get_running_loop()
61
+ with _READINESS_LOCKS_GUARD:
62
+ lock = _READINESS_LOCKS.get(loop)
63
+ if lock is None:
64
+ lock = asyncio.Lock()
65
+ _READINESS_LOCKS[loop] = lock
66
+ return lock
67
+
68
+
69
+ def _run_checks() -> dict[str, dict[str, Any]]:
70
+ """Run the lightweight-but-real component checks synchronously."""
71
+ from backend.core.functions_lookup import FunctionEncyclopedia
72
+ from backend.core.nl2sql import NL2SQLGenerator
73
+ from backend.core.transpiler import SQLTranspiler
74
+ from backend.core.type_mapping import TypeMapper
75
+
76
+ transpiler = SQLTranspiler()
77
+ function_encyclopedia = FunctionEncyclopedia()
78
+ type_mapper = TypeMapper()
79
+ nl2sql_generator = NL2SQLGenerator()
80
+
81
+ checks: dict[str, dict[str, Any]] = {}
82
+ try:
83
+ result = transpiler.transpile("SELECT 1 AS readiness_probe", "mysql", "postgres")
84
+ checks["transpiler"] = {"status": "ok" if result.success else "error", "test_result": result.success}
85
+ except Exception:
86
+ logger.exception("Readiness transpiler probe failed")
87
+ checks["transpiler"] = {"status": "error", "code": "probe_failed"}
88
+ try:
89
+ function = function_encyclopedia.get_function("CONCAT")
90
+ checks["functions"] = {"status": "ok" if function else "error", "sample_lookup": "CONCAT" if function else None}
91
+ except Exception:
92
+ logger.exception("Readiness function probe failed")
93
+ checks["functions"] = {"status": "error", "code": "probe_failed"}
94
+ try:
95
+ mapping = type_mapper.map_type("VARCHAR", "mysql", "postgres")
96
+ checks["types"] = {"status": "ok" if mapping.get("success") else "error", "sample_mapping": mapping.get("target_type")}
97
+ except Exception:
98
+ logger.exception("Readiness type probe failed")
99
+ checks["types"] = {"status": "error", "code": "probe_failed"}
100
+ try:
101
+ generated = nl2sql_generator.generate("查询所有用户", "mysql")
102
+ checks["nl2sql"] = {"status": "ok" if generated.success else "error", "confidence": generated.confidence}
103
+ except Exception:
104
+ logger.exception("Readiness NL2SQL probe failed")
105
+ checks["nl2sql"] = {"status": "error", "code": "probe_failed"}
106
+ return checks
107
+
108
+
109
+ async def _get_checks() -> dict[str, dict[str, Any]]:
110
+ """Run at most one expensive probe at a time per event loop and reuse it briefly."""
111
+ now = time.monotonic()
112
+ cached_checks = globals().get("_cached_checks", _READINESS_CACHE["checks"])
113
+ cached_at = globals().get("_cached_at", _READINESS_CACHE["cached_at"])
114
+ if cached_checks is not None and now - cached_at < _READINESS_CACHE_TTL_SECONDS:
115
+ return cached_checks
116
+ async with _get_readiness_lock():
117
+ now = time.monotonic()
118
+ cached_checks = globals().get("_cached_checks", _READINESS_CACHE["checks"])
119
+ cached_at = globals().get("_cached_at", _READINESS_CACHE["cached_at"])
120
+ if cached_checks is not None and now - cached_at < _READINESS_CACHE_TTL_SECONDS:
121
+ return cached_checks
122
+ try:
123
+ checks = await asyncio.wait_for(asyncio.to_thread(_run_checks), timeout=_READINESS_TIMEOUT_SECONDS)
124
+ except asyncio.TimeoutError:
125
+ logger.error("Readiness probe timed out after %.1fs", _READINESS_TIMEOUT_SECONDS)
126
+ checks = {name: {"status": "error", "code": "probe_timeout"} for name in ("transpiler", "functions", "types", "nl2sql")}
127
+ except Exception:
128
+ logger.exception("Readiness probe failed unexpectedly")
129
+ checks = {"probe": {"status": "error", "code": "probe_failed"}}
130
+ _READINESS_CACHE["checks"] = checks
131
+ _READINESS_CACHE["cached_at"] = time.monotonic()
132
+ globals()["_cached_checks"] = checks
133
+ globals()["_cached_at"] = _READINESS_CACHE["cached_at"]
134
+ return checks
135
+
136
+
137
+ def _response_status(checks: dict[str, dict[str, Any]]) -> tuple[int, bool]:
138
+ """Return the HTTP status and health flag for a probe result."""
139
+ failed = [name for name, check in checks.items() if check.get("status") != "ok"]
140
+ return (200 if not failed else 503, not failed)
141
+
142
+
143
+ async def readiness_response(request: Request) -> JSONResponse:
144
+ """Return HTTP 200 only when all core service probes pass."""
145
+ checks = await _get_checks()
146
+ status_code, healthy = _response_status(checks)
147
+ return JSONResponse(
148
+ status_code=status_code,
149
+ content={
150
+ "status": "ready" if healthy else "not_ready",
151
+ "version": _api_version(),
152
+ "probe": "readiness",
153
+ "checks": checks,
154
+ "timestamp": datetime.now().isoformat(),
155
+ },
156
+ )
157
+
158
+
159
+ async def deep_health_response(request: Request) -> JSONResponse:
160
+ """Return deep health using the same cached dependency probes as readiness."""
161
+ checks = await _get_checks()
162
+ status_code, healthy = _response_status(checks)
163
+ return JSONResponse(
164
+ status_code=status_code,
165
+ content={
166
+ "status": "✅ healthy" if healthy else "❌ unhealthy",
167
+ "version": _api_version(),
168
+ "checks": checks,
169
+ "timestamp": datetime.now().isoformat(),
170
+ },
171
+ )
172
+
173
+
174
+ def _api_version() -> str:
175
+ """Resolve API version without importing the API module back into readiness."""
176
+ from backend.core.config import settings
177
+
178
+ return settings.api_version
@@ -0,0 +1,57 @@
1
+ # SQL Dialect Master - Core Module
2
+ from .config import (
3
+ setup_logging,
4
+ SUPPORTED_DIALECTS,
5
+ DIALECT_METADATA,
6
+ settings,
7
+ AppSettings,
8
+ get_dialect_ui_info,
9
+ get_dialect_api_info,
10
+ get_dialect_label,
11
+ )
12
+ from .parser import SQLParser, ParseResult
13
+ from .transpiler import SQLTranspiler, TranspileResult
14
+ from .semantic_diff import SQLSemanticDiffer, SemanticDiff, diff_sql_ast
15
+ from .post_processor import PostProcessor
16
+ from .functions_lookup import FunctionEncyclopedia
17
+ from .type_mapping import TypeMapper
18
+ from .nl2sql import NL2SQLGenerator, NL2SQLResult
19
+ from .cache import TTLCache
20
+ from .exceptions import (
21
+ SDMException,
22
+ UnsupportedDialectError,
23
+ TranspileError,
24
+ ParseError,
25
+ SecurityViolationError,
26
+ ValidationError,
27
+ )
28
+
29
+ __all__ = [
30
+ "setup_logging",
31
+ "SUPPORTED_DIALECTS",
32
+ "DIALECT_METADATA",
33
+ "settings",
34
+ "AppSettings",
35
+ "get_dialect_ui_info",
36
+ "get_dialect_api_info",
37
+ "get_dialect_label",
38
+ "SQLParser",
39
+ "ParseResult",
40
+ "SQLTranspiler",
41
+ "TranspileResult",
42
+ "SQLSemanticDiffer",
43
+ "SemanticDiff",
44
+ "diff_sql_ast",
45
+ "TTLCache",
46
+ "PostProcessor",
47
+ "FunctionEncyclopedia",
48
+ "TypeMapper",
49
+ "NL2SQLGenerator",
50
+ "NL2SQLResult",
51
+ "SDMException",
52
+ "UnsupportedDialectError",
53
+ "TranspileError",
54
+ "ParseError",
55
+ "SecurityViolationError",
56
+ "ValidationError",
57
+ ]