sql-dialect-master 1.1.2__tar.gz → 1.1.3__tar.gz

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 (98) hide show
  1. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/PKG-INFO +11 -3
  2. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/README.md +8 -1
  3. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/api/main.py +38 -9
  4. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/api/middleware.py +34 -15
  5. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/api/rate_limit_store.py +19 -2
  6. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/api/readiness.py +25 -7
  7. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/cache.py +12 -2
  8. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/config.py +36 -13
  9. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/metadata.py +2 -2
  10. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/core_extract.py +26 -4
  11. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/templates.py +2 -2
  12. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/orchestrator.py +30 -0
  13. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/renderer.py +40 -5
  14. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/templates.py +16 -0
  15. sql_dialect_master-1.1.3/backend/core/nl2sql_pipeline/validation.py +76 -0
  16. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/p1_sql_scanner.py +18 -6
  17. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/parser.py +50 -2
  18. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/transpiler.py +76 -24
  19. sql_dialect_master-1.1.3/backend/frontend_entrypoint.py +22 -0
  20. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/core/design_tokens.py +7 -3
  21. sql_dialect_master-1.1.3/frontend/core/fingerprints.py +24 -0
  22. sql_dialect_master-1.1.3/frontend/core/sql_inputs.py +29 -0
  23. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/core/state.py +81 -26
  24. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/core/viewmodels.py +22 -2
  25. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/convert.py +104 -16
  26. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/diff.py +15 -2
  27. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/functions.py +2 -3
  28. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/history.py +9 -6
  29. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/lineage.py +31 -14
  30. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/nl2sql.py +13 -3
  31. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/query_analysis.py +12 -4
  32. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/runtime.py +3 -3
  33. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/templates.py +18 -1
  34. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/ui/command_palette.py +21 -2
  35. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/pyproject.toml +13 -2
  36. sql_dialect_master-1.1.3/sdm_local_v2.py +219 -0
  37. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/sql_dialect_master.egg-info/PKG-INFO +11 -3
  38. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/sql_dialect_master.egg-info/SOURCES.txt +5 -0
  39. sql_dialect_master-1.1.3/sql_dialect_master.egg-info/entry_points.txt +2 -0
  40. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/sql_dialect_master.egg-info/requires.txt +2 -1
  41. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/sql_dialect_master.egg-info/top_level.txt +1 -0
  42. sql_dialect_master-1.1.2/backend/core/nl2sql_pipeline/validation.py +0 -28
  43. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/LICENSE +0 -0
  44. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/__init__.py +0 -0
  45. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/api/__init__.py +0 -0
  46. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/__init__.py +0 -0
  47. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/column_hint_validation.py +0 -0
  48. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/constants.py +0 -0
  49. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/exceptions.py +0 -0
  50. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/function_call_scanner.py +0 -0
  51. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/function_semantics.py +0 -0
  52. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/functions_db.json +0 -0
  53. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/functions_lookup.py +0 -0
  54. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql.py +0 -0
  55. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/__init__.py +0 -0
  56. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/aggregations.py +0 -0
  57. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/boolean_conditions.py +0 -0
  58. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/evidence.py +0 -0
  59. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/mappings.py +0 -0
  60. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/relations.py +0 -0
  61. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/tokenizer.py +0 -0
  62. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_legacy.py +0 -0
  63. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/__init__.py +0 -0
  64. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/aggregations.py +0 -0
  65. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/clause_extract.py +0 -0
  66. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/dialect.py +0 -0
  67. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/intent.py +0 -0
  68. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/ir.py +0 -0
  69. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/models.py +0 -0
  70. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/predicates.py +0 -0
  71. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/resolution.py +0 -0
  72. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/suggestions.py +0 -0
  73. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/p2_data_validation.py +0 -0
  74. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/post_processor.py +0 -0
  75. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/rules.py +0 -0
  76. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/semantic_diff.py +0 -0
  77. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/type_mapping.json +0 -0
  78. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/type_mapping.py +0 -0
  79. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/utils/__init__.py +0 -0
  80. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/utils/validators.py +0 -0
  81. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/__init__.py +0 -0
  82. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/app_context_v2.py +0 -0
  83. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/core/__init__.py +0 -0
  84. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/core/escaping.py +0 -0
  85. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/core/navigation.py +0 -0
  86. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/core/styles.py +0 -0
  87. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/core/themes.py +0 -0
  88. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/core/type_status.py +0 -0
  89. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/__init__.py +0 -0
  90. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/settings.py +0 -0
  91. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/types.py +0 -0
  92. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/templates_v2.py +0 -0
  93. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/ui/__init__.py +0 -0
  94. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/ui/diff.py +0 -0
  95. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/ui/editor.py +0 -0
  96. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/ui/status.py +0 -0
  97. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/setup.cfg +0 -0
  98. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/sql_dialect_master.egg-info/dependency_links.txt +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-dialect-master
3
- Version: 1.1.2
3
+ Version: 1.1.3
4
4
  Summary: SQL Dialect Master - Multi-database SQL conversion engine
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
7
  License-File: LICENSE
8
- Requires-Dist: sqlglot>=25.0.0
8
+ Requires-Dist: sqlglot<31.0.0,>=30.18.0
9
9
  Requires-Dist: streamlit>=1.38.0
10
10
  Requires-Dist: fastapi>=0.115.0
11
11
  Requires-Dist: uvicorn>=0.30.0
@@ -16,6 +16,7 @@ Requires-Dist: hypothesis>=6.112.0; extra == "dev"
16
16
  Requires-Dist: pytest>=8.3.0; extra == "dev"
17
17
  Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
18
18
  Requires-Dist: httpx>=0.27.0; extra == "dev"
19
+ Requires-Dist: requests>=2.31.0; extra == "dev"
19
20
  Provides-Extra: qa
20
21
  Requires-Dist: ruff>=0.6; extra == "qa"
21
22
  Requires-Dist: bandit>=1.7; extra == "qa"
@@ -31,7 +32,7 @@ Dynamic: license-file
31
32
 
32
33
  Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
33
34
 
34
- Current release: 1.1.2
35
+ Current release: 1.1.3
35
36
 
36
37
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
37
38
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -95,6 +96,13 @@ python -m pip install -e ".[dev,semantic]"
95
96
  streamlit run sdm_local_v2.py
96
97
  ```
97
98
 
99
+ After installing the package, the same workspace can be launched from any
100
+ working directory with:
101
+
102
+ ```bash
103
+ sdm-streamlit
104
+ ```
105
+
98
106
  Open http://localhost:8501 in your browser.
99
107
 
100
108
  ### Run FastAPI Backend
@@ -2,7 +2,7 @@
2
2
 
3
3
  Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
4
4
 
5
- Current release: 1.1.2
5
+ Current release: 1.1.3
6
6
 
7
7
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -66,6 +66,13 @@ python -m pip install -e ".[dev,semantic]"
66
66
  streamlit run sdm_local_v2.py
67
67
  ```
68
68
 
69
+ After installing the package, the same workspace can be launched from any
70
+ working directory with:
71
+
72
+ ```bash
73
+ sdm-streamlit
74
+ ```
75
+
69
76
  Open http://localhost:8501 in your browser.
70
77
 
71
78
  ### Run FastAPI Backend
@@ -11,6 +11,7 @@ Enterprise-grade multi-database SQL conversion API:
11
11
  Exact counts are derived from the canonical data files at runtime —
12
12
  see backend.core.metadata — and must not be restated here.
13
13
  """
14
+ import asyncio
14
15
  import logging
15
16
  import time
16
17
  from datetime import datetime
@@ -19,6 +20,7 @@ from typing import Any, Dict, List, Optional
19
20
  from fastapi import FastAPI, HTTPException, Query, Request
20
21
  from fastapi.middleware.cors import CORSMiddleware
21
22
  from fastapi.responses import JSONResponse
23
+ from starlette.concurrency import run_in_threadpool
22
24
  from pydantic import BaseModel, ConfigDict, Field
23
25
 
24
26
  from backend.core.config import get_dialect_api_info, settings, setup_logging
@@ -32,10 +34,27 @@ from backend.core.type_mapping import TypeMapper
32
34
 
33
35
  from backend.api.readiness import health_probe_response
34
36
 
35
- # Configure logging for the application
36
- logger = setup_logging(level=logging.INFO)
37
+ # Configure logging for the application from the validated settings object.
38
+ # Keep an INFO fallback for defensive compatibility with custom settings
39
+ # objects, while normal AppSettings values are resolved from SDM_LOG_LEVEL.
40
+ _log_level = getattr(logging, settings.log_level.upper(), logging.INFO)
41
+ logger = setup_logging(level=_log_level, log_file=settings.log_file or None)
37
42
  api_logger = logging.getLogger(__name__)
38
43
 
44
+
45
+ async def _run_cpu_bound(func, *args):
46
+ """Run parser/transpiler work off-loop with the configured timeout."""
47
+ try:
48
+ return await asyncio.wait_for(
49
+ run_in_threadpool(func, *args),
50
+ timeout=settings.request_timeout,
51
+ )
52
+ except asyncio.TimeoutError as exc:
53
+ raise HTTPException(
54
+ status_code=504,
55
+ detail="Request processing timed out",
56
+ ) from exc
57
+
39
58
  # API metadata
40
59
  API_VERSION = settings.api_version
41
60
  API_TITLE = settings.api_title
@@ -199,7 +218,10 @@ class ConvertRequest(BaseModel):
199
218
  sql: str = Field(..., description="Source SQL statement to convert")
200
219
  source_dialect: str = Field(..., description="Source database dialect")
201
220
  target_dialect: str = Field(..., description="Target database dialect")
202
- pretty: bool = Field(True, description="Format output SQL with indentation")
221
+ pretty: bool = Field(
222
+ settings.transpiler_pretty_default,
223
+ description="Format output SQL with indentation",
224
+ )
203
225
 
204
226
  model_config = ConfigDict(json_schema_extra={
205
227
  "example": {
@@ -402,11 +424,14 @@ async def convert_sql(request: ConvertRequest):
402
424
  detail=f"Unsupported target dialect: {request.target_dialect}. Supported: {SUPPORTED_DIALECTS}"
403
425
  )
404
426
 
405
- result = transpiler.transpile(
427
+ # sqlglot parsing/transpilation is CPU-bound. Keep it off the event loop
428
+ # so one large request cannot stall unrelated async requests.
429
+ result = await _run_cpu_bound(
430
+ transpiler.transpile,
406
431
  request.sql,
407
432
  source_dialect,
408
433
  target_dialect,
409
- request.pretty
434
+ request.pretty,
410
435
  )
411
436
  return ConvertResponse(
412
437
  success=result.success,
@@ -431,7 +456,10 @@ async def parse_sql(request: ParseRequest):
431
456
  """
432
457
  dialect = normalize_dialect(request.dialect)
433
458
  parser = SQLParser(dialect)
434
- elements = parser.extract_elements(request.sql)
459
+ parsed = await _run_cpu_bound(parser.parse, request.sql)
460
+ if not parsed.success:
461
+ raise HTTPException(status_code=422, detail=parsed.error or "SQL parsing failed")
462
+ elements = await _run_cpu_bound(parser.extract_elements, request.sql)
435
463
  return {
436
464
  "success": True,
437
465
  "dialect": dialect,
@@ -458,7 +486,7 @@ async def list_functions(
458
486
  if search:
459
487
  functions = func_encyclopedia.search(search, limit)
460
488
  elif category:
461
- functions = func_encyclopedia.list_by_category(category)
489
+ functions = func_encyclopedia.list_by_category(category)[:limit]
462
490
  else:
463
491
  functions = func_encyclopedia.functions[:limit]
464
492
 
@@ -598,7 +626,8 @@ async def generate_sql(request: NL2SQLRequest):
598
626
  "dotted identifier"
599
627
  ),
600
628
  )
601
- result = nl2sql_generator.generate(
629
+ result = await _run_cpu_bound(
630
+ nl2sql_generator.generate,
602
631
  request.text,
603
632
  dialect,
604
633
  request.table_hint,
@@ -611,7 +640,7 @@ async def generate_sql(request: NL2SQLRequest):
611
640
  dialect=dialect,
612
641
  explanation=result.explanation,
613
642
  confidence=result.confidence,
614
- suggestions=result.suggestions
643
+ suggestions=list(result.suggestions[: settings.nl2sql_max_suggestions])
615
644
  )
616
645
 
617
646
  @app.get("/health", tags=["system"])
@@ -75,7 +75,11 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
75
75
 
76
76
  async def _read_bounded_body(self, request: Request) -> Response | None:
77
77
  """Consume and cache request bodies without trusting Content-Length."""
78
- if request.method in {"GET", "HEAD"}:
78
+ # HEAD has no response body by definition and some ASGI servers do
79
+ # not expose a request stream for it. GET bodies are unusual but
80
+ # still need the same bound so an oversized payload cannot bypass
81
+ # request-size protection by changing the HTTP method.
82
+ if request.method == "HEAD":
79
83
  return None
80
84
  content_length = request.headers.get("content-length")
81
85
  if content_length:
@@ -83,6 +87,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
83
87
  declared_length = int(content_length)
84
88
  except ValueError:
85
89
  return JSONResponse(status_code=400, content={"success": False, "error": {"code": 400, "message": "Invalid Content-Length"}, "timestamp": datetime.now().isoformat()})
90
+ if declared_length < 0:
91
+ return JSONResponse(status_code=400, content={"success": False, "error": {"code": 400, "message": "Invalid Content-Length"}, "timestamp": datetime.now().isoformat()})
86
92
  if declared_length > self.max_body_bytes:
87
93
  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
94
 
@@ -97,6 +103,28 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
97
103
  return None
98
104
 
99
105
  async def dispatch(self, request: Request, call_next) -> Response:
106
+ is_health_probe = (
107
+ request.url.path in {"/health", "/", "/ready", "/health/deep"}
108
+ and request.method in {"GET", "HEAD"}
109
+ )
110
+
111
+ # Apply rate limiting before JSON/body parsing. This prevents a
112
+ # client from spending unbounded CPU on invalid payloads while still
113
+ # keeping the request-size guard active when rate limiting is disabled.
114
+ rate_limit_checked = False
115
+ remaining = reset_time = None
116
+ if self.enabled and not is_health_probe:
117
+ client_id = self._get_client_id(request)
118
+ is_allowed, remaining, reset_time = self.limiter.is_allowed(client_id)
119
+ rate_limit_checked = True
120
+ if not is_allowed:
121
+ logger.warning("Rate limit exceeded for client: %s", _sanitize_log_value(client_id))
122
+ return JSONResponse(
123
+ status_code=429,
124
+ content={"success": False, "error": {"code": 429, "message": "Rate limit exceeded", "retry_after": reset_time}, "timestamp": datetime.now().isoformat()},
125
+ 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)},
126
+ )
127
+
100
128
  body_error = await self._read_bounded_body(request)
101
129
  if body_error is not None:
102
130
  return body_error
@@ -118,24 +146,15 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
118
146
 
119
147
  if not self.enabled:
120
148
  return await call_next(request)
121
- if request.url.path in {"/health", "/", "/ready", "/health/deep"} and request.method in {"GET", "HEAD"}:
149
+ if is_health_probe:
122
150
  if request.url.path in {"/ready", "/health/deep"}:
123
151
  return await health_probe_response(request)
124
152
  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
153
  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)
154
+ if rate_limit_checked:
155
+ response.headers["X-RateLimit-Limit"] = str(self.limiter._requests_per_window)
156
+ response.headers["X-RateLimit-Remaining"] = str(remaining)
157
+ response.headers["X-RateLimit-Reset"] = str(reset_time)
139
158
  return response
140
159
 
141
160
  def _get_client_id(self, request: Request) -> str:
@@ -65,6 +65,8 @@ class InMemoryRateLimitStore:
65
65
  class RedisRateLimitStore:
66
66
  """Atomic fixed-window store backed by Redis for multi-worker deployments."""
67
67
 
68
+ DEFAULT_TIMEOUT_SECONDS = 3.0
69
+
68
70
  _SCRIPT = """
69
71
  local count = redis.call('INCR', KEYS[1])
70
72
  if count == 1 then
@@ -74,14 +76,29 @@ class RedisRateLimitStore:
74
76
  return {count, ttl}
75
77
  """
76
78
 
77
- def __init__(self, url: str, namespace: str = "sdm:rate-limit:") -> None:
79
+ def __init__(
80
+ self,
81
+ url: str,
82
+ namespace: str = "sdm:rate-limit:",
83
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
84
+ ) -> None:
78
85
  if not url:
79
86
  raise ValueError("Redis URL is required when rate-limit backend is redis")
87
+ if timeout_seconds <= 0:
88
+ raise ValueError("timeout_seconds must be positive")
80
89
  try:
81
90
  import redis
82
91
  except ImportError as exc:
83
92
  raise RuntimeError("Redis rate limiting requires the optional 'redis' package") from exc
84
- self._client = redis.Redis.from_url(url, decode_responses=False)
93
+ # Bound both connection establishment and command execution. A
94
+ # stalled Redis node must not block API startup or every request that
95
+ # checks the distributed rate-limit store.
96
+ self._client = redis.Redis.from_url(
97
+ url,
98
+ decode_responses=False,
99
+ socket_connect_timeout=timeout_seconds,
100
+ socket_timeout=timeout_seconds,
101
+ )
85
102
  self._client.ping()
86
103
  self._script = self._client.register_script(self._SCRIPT)
87
104
  self._namespace = namespace
@@ -7,6 +7,7 @@ import logging
7
7
  import os
8
8
  import secrets
9
9
  import time
10
+ import uuid
10
11
  import weakref
11
12
  from datetime import datetime
12
13
  from typing import Any
@@ -16,6 +17,8 @@ from fastapi.responses import JSONResponse
16
17
 
17
18
  logger = logging.getLogger(__name__)
18
19
 
20
+ from backend.core.config import settings
21
+
19
22
  _READINESS_CACHE_TTL_SECONDS = 5.0
20
23
  _READINESS_TIMEOUT_SECONDS = 3.0
21
24
  _READINESS_LOCKS: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = weakref.WeakKeyDictionary()
@@ -26,22 +29,37 @@ _HEALTH_PROBE_PATHS = {"/ready", "/health/deep"}
26
29
 
27
30
  def _probe_allowed(request: Request) -> bool:
28
31
  """Allow health probes only from loopback or with the configured probe secret."""
29
- token = os.getenv("SDM_HEALTH_PROBE_TOKEN", "").strip()
32
+ configured = settings.health_probe_token
33
+ token = configured.get_secret_value().strip() if configured else ""
34
+ # Keep process-level test/dev overrides effective after module import;
35
+ # production configuration still comes from the validated singleton above
36
+ # (including values loaded from .env).
37
+ if not token:
38
+ token = os.environ.get("SDM_HEALTH_PROBE_TOKEN", "").strip()
30
39
  supplied = request.headers.get("X-Health-Probe-Token", "")
31
40
  if token and supplied and secrets.compare_digest(supplied, token):
32
41
  return True
42
+ # Once a token is configured, require it even for loopback callers. This
43
+ # avoids trusting an unverified X-Forwarded-For chain in front of a proxy.
44
+ if token:
45
+ return False
33
46
  client = request.client
34
47
  return bool(client and client.host in {"127.0.0.1", "::1", "localhost"})
35
48
 
36
49
 
37
- def _health_probe_denied_response() -> JSONResponse:
50
+ def _health_probe_denied_response(request: Request) -> JSONResponse:
38
51
  """Return a stable denial response for public health probe requests."""
52
+ request_id = getattr(request.state, "request_id", None) or str(uuid.uuid4())
53
+ headers = {"X-Request-ID": request_id}
54
+ content = {
55
+ "success": False,
56
+ "error": {"code": 403, "message": "health probe access denied"},
57
+ }
58
+ content["request_id"] = request_id
39
59
  return JSONResponse(
40
60
  status_code=403,
41
- content={
42
- "success": False,
43
- "error": {"code": 403, "message": "health probe access denied"},
44
- },
61
+ content=content,
62
+ headers=headers,
45
63
  )
46
64
 
47
65
 
@@ -50,7 +68,7 @@ async def health_probe_response(request: Request) -> JSONResponse:
50
68
  if request.url.path not in _HEALTH_PROBE_PATHS or request.method not in {"GET", "HEAD"}:
51
69
  raise ValueError("health_probe_response called for a non-probe request")
52
70
  if not _probe_allowed(request):
53
- return _health_probe_denied_response()
71
+ return _health_probe_denied_response(request)
54
72
  if request.url.path == "/ready":
55
73
  return await readiness_response(request)
56
74
  return await deep_health_response(request)
@@ -48,6 +48,8 @@ class TTLCache:
48
48
  """
49
49
  if max_size <= 0:
50
50
  raise ValueError("max_size must be greater than 0")
51
+ if ttl < 0:
52
+ raise ValueError("ttl must be greater than or equal to 0")
51
53
  self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
52
54
  self._max_size = max_size
53
55
  self._ttl = ttl
@@ -222,10 +224,18 @@ class TTLCache:
222
224
  except Exception as e:
223
225
  logger.error(f"Background cleanup error: {e}")
224
226
 
225
- thread = threading.Thread(target=cleanup_loop, daemon=True, name="CacheCleanup")
226
227
  with self._lock:
228
+ # Check, assign and start under the same lock. Keeping the
229
+ # assignment inside the critical section closes the TOCTOU window
230
+ # where two concurrent callers both observe no live thread and
231
+ # each start a cleanup worker.
232
+ if self._cleanup_thread is not None and self._cleanup_thread.is_alive():
233
+ raise RuntimeError(
234
+ "background cleanup is already running for this cache"
235
+ )
236
+ thread = threading.Thread(target=cleanup_loop, daemon=True, name="CacheCleanup")
227
237
  self._cleanup_thread = thread
228
- thread.start()
238
+ thread.start()
229
239
  logger.info(f"Started background cache cleanup (interval={interval}s)")
230
240
  return thread
231
241
 
@@ -14,7 +14,7 @@ import sys
14
14
  from enum import Enum
15
15
  from typing import Any, Dict, List, Literal, Optional, TypedDict
16
16
 
17
- from pydantic import Field, SecretStr
17
+ from pydantic import Field, SecretStr, field_validator, model_validator
18
18
  from pydantic_settings import BaseSettings, SettingsConfigDict
19
19
 
20
20
  # =============================================================================
@@ -220,18 +220,20 @@ class AppSettings(BaseSettings):
220
220
  "http://localhost:8501,http://localhost:8000,"
221
221
  "http://127.0.0.1:8501,http://127.0.0.1:8000"
222
222
  )
223
- max_batch_size: int = 100
224
- request_timeout: int = 30
223
+ max_batch_size: int = Field(100, gt=0)
224
+ request_timeout: int = Field(30, gt=0)
225
225
 
226
226
  # Cache settings
227
227
  cache_enabled: bool = True
228
- cache_ttl: int = 300
228
+ # Zero intentionally disables expiry (TTLCache contract); negatives are
229
+ # invalid because they are indistinguishable from that mode at runtime.
230
+ cache_ttl: int = Field(300, ge=0)
229
231
  cache_max_size: int = Field(1000, gt=0)
230
232
 
231
233
  # Rate limiting
232
234
  rate_limit_enabled: bool = True
233
- rate_limit_requests: int = 100
234
- rate_limit_window: int = 60
235
+ rate_limit_requests: int = Field(100, gt=0)
236
+ rate_limit_window: int = Field(60, gt=0)
235
237
  # Storage backend selection: strict lowercase values; no case or
236
238
  # whitespace normalization — a Literal rejects any other spelling at
237
239
  # settings construction instead of silently coercing it.
@@ -243,9 +245,9 @@ class AppSettings(BaseSettings):
243
245
  redis_url: Optional[SecretStr] = None
244
246
 
245
247
  # NL2SQL settings
246
- nl2sql_confidence_threshold: float = 0.6
247
- nl2sql_max_suggestions: int = 5
248
- nl2sql_max_input_length: int = 8192
248
+ nl2sql_confidence_threshold: float = Field(0.6, ge=0.0, le=1.0)
249
+ nl2sql_max_suggestions: int = Field(5, ge=0)
250
+ nl2sql_max_input_length: int = Field(8192, gt=0)
249
251
 
250
252
  # SQL Transpiler / Parser settings
251
253
  #
@@ -254,12 +256,16 @@ class AppSettings(BaseSettings):
254
256
  # to ``transpiler_max_sql_length``. Business code must read the
255
257
  # settings object — never ``os.getenv`` directly.
256
258
  transpiler_pretty_default: bool = True
257
- transpiler_max_sql_length: int = 100000
258
- parser_max_sql_length: int = 100000
259
+ transpiler_max_sql_length: int = Field(100000, gt=0)
260
+ parser_max_sql_length: Optional[int] = Field(None, gt=0)
259
261
 
260
262
  # Function encyclopedia
261
- function_search_limit: int = 50
262
- function_fuzzy_threshold: float = 0.4
263
+ function_search_limit: int = Field(50, gt=0)
264
+ function_fuzzy_threshold: float = Field(0.4, ge=0.0, le=1.0)
265
+
266
+ # Readiness probes. SecretStr keeps the value out of settings reprs and
267
+ # structured logs while still allowing deployment through SDM_ env vars.
268
+ health_probe_token: Optional[SecretStr] = None
263
269
 
264
270
  # Security settings
265
271
  security_check_enabled: bool = True
@@ -268,6 +274,23 @@ class AppSettings(BaseSettings):
268
274
  # Logging settings
269
275
  log_level: str = "INFO"
270
276
  log_file: str = ""
277
+
278
+ @model_validator(mode="after")
279
+ def _apply_parser_length_fallback(self) -> "AppSettings":
280
+ """Keep parser and transpiler limits aligned unless explicitly set."""
281
+ if self.parser_max_sql_length is None:
282
+ self.parser_max_sql_length = self.transpiler_max_sql_length
283
+ return self
284
+
285
+ @field_validator("log_level", mode="before")
286
+ @classmethod
287
+ def _normalize_log_level(cls, value: str) -> str:
288
+ normalized = str(value).strip().upper()
289
+ if normalized not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}:
290
+ raise ValueError(
291
+ "log_level must be one of DEBUG, INFO, WARNING, ERROR, CRITICAL"
292
+ )
293
+ return normalized
271
294
 
272
295
  def to_dict(self) -> Dict[str, Any]:
273
296
  """Convert settings to dictionary."""
@@ -17,8 +17,8 @@ Design rules:
17
17
  from __future__ import annotations
18
18
 
19
19
  # ── Release version facts (sole source of truth) ────────────────────────
20
- PROJECT_VERSION = "1.1.2"
21
- API_VERSION = "1.1.2"
20
+ PROJECT_VERSION = "1.1.3"
21
+ API_VERSION = "1.1.3"
22
22
  # Frontend v2 architecture — independent of the product release version.
23
23
  FRONTEND_ARCHITECTURE_VERSION = 2
24
24
 
@@ -9,6 +9,12 @@ import re
9
9
  from typing import Dict, List, Optional
10
10
 
11
11
 
12
+ # Generated SQL is intended to be reviewed and parameterized by a caller.
13
+ # Refuse unbounded/absurd row counts before integer conversion (which can
14
+ # raise ValueError for thousands of digits on modern Python versions).
15
+ MAX_NL2SQL_LIMIT = 1_000_000
16
+
17
+
12
18
  def detect_operation(text: str, keywords: Dict[str, str]) -> str:
13
19
  """Detect SQL operation type from text.
14
20
 
@@ -101,14 +107,30 @@ def extract_limit(text: str) -> Optional[int]:
101
107
  '前10', 'top 10', 'limit 10', '10条', '10行'.
102
108
  """
103
109
  patterns = [
104
- r"前\s*(\d+)", r"top\s*(\d+)", r"limit\s*(\d+)",
105
- r"first\s*(\d+)", r"(\d+)\s*条", r"(\d+)\s*个",
106
- r"(\d+)\s*行", r"(\d+)\s*rows?", r"only\s*(\d+)",
110
+ r"前\s*(-?\d+)", r"top\s*(-?\d+)", r"limit\s*(-?\d+)",
111
+ r"first\s*(-?\d+)", r"(-?\d+)\s*条", r"(-?\d+)\s*个",
112
+ r"(-?\d+)\s*行", r"(-?\d+)\s*rows?", r"only\s*(-?\d+)",
107
113
  ]
108
114
  for pattern in patterns:
109
115
  match = re.search(pattern, text, re.IGNORECASE)
110
116
  if match:
111
- return int(match.group(1))
117
+ raw_value = match.group(1)
118
+ # Keep the conversion bounded and deterministic. Raising here is
119
+ # handled by the generator as a client-facing failed generation;
120
+ # silently dropping an invalid limit could produce an unbounded
121
+ # query.
122
+ if raw_value.startswith("-"):
123
+ raise ValueError("LIMIT cannot be negative")
124
+ if len(raw_value) > len(str(MAX_NL2SQL_LIMIT)):
125
+ raise ValueError(
126
+ f"LIMIT exceeds maximum supported value of {MAX_NL2SQL_LIMIT}"
127
+ )
128
+ value = int(raw_value)
129
+ if value > MAX_NL2SQL_LIMIT:
130
+ raise ValueError(
131
+ f"LIMIT exceeds maximum supported value of {MAX_NL2SQL_LIMIT}"
132
+ )
133
+ return value
112
134
  return None
113
135
 
114
136
 
@@ -86,7 +86,7 @@ class QueryTemplate:
86
86
  DEFAULT_QUERY_TEMPLATES = [
87
87
  QueryTemplate(
88
88
  name="top_n_query",
89
- pattern=rf"(?:查询|获取|get|show|find)?\s*(?:前|top|bottom|最低|最少|lowest|smallest)\s*(\d+)\s*(?:个|条|名)?\s*(.{{1,{_TEMPLATE_CAPTURE_LIMIT}}}?)(?:按|by)?\s*(.{{1,{_TEMPLATE_CAPTURE_LIMIT}}})?\s*(?:排序|排列|order)?",
89
+ pattern=rf"(?:查询|获取|get|show|find)?\s*(?:前|top|bottom|最低|最少|lowest|smallest)\s*(-?\d+)\s*(?:个|条|名)?\s*(.{{1,{_TEMPLATE_CAPTURE_LIMIT}}}?)(?:按|by)?\s*(.{{1,{_TEMPLATE_CAPTURE_LIMIT}}})?\s*(?:排序|排列|order)?",
90
90
  sql_template="SELECT * FROM {table} ORDER BY {order_col} DESC LIMIT {n}",
91
91
  priority=90,
92
92
  ),
@@ -98,7 +98,7 @@ DEFAULT_QUERY_TEMPLATES = [
98
98
  ),
99
99
  QueryTemplate(
100
100
  name="time_range_query",
101
- pattern=r"(?:查询|获取|get|find)?\s*(?:最近|过去|last|past)\s*(\d+)\s*(天|周|月|年|days?|weeks?|months?|years?)(?:的|内的)?\s*(.+)",
101
+ pattern=r"(?:查询|获取|get|find)?\s*(?:最近|过去|last|past)\s*(-?\d+)\s*(天|周|月|年|days?|weeks?|months?|years?)(?:的|内的)?\s*(.+)",
102
102
  sql_template="SELECT * FROM {table} WHERE {date_col} >= DATE_SUB(CURRENT_DATE, {interval})",
103
103
  priority=85,
104
104
  ),
@@ -219,6 +219,36 @@ class PipelineNL2SQLGenerator:
219
219
  suggestions=["Please shorten the natural-language query and try again."],
220
220
  evidence=GenerationEvidence(),
221
221
  )
222
+ # Validate an explicitly requested row limit before the extraction
223
+ # stage. This turns huge numeric literals into a controlled failed
224
+ # generation instead of an uncaught int-conversion exception (500 at
225
+ # the API boundary), and prevents silently dropping the limit.
226
+ try:
227
+ self._extract_limit(text)
228
+ except ValueError as exc:
229
+ return NL2SQLResult(
230
+ success=False,
231
+ input_text=text,
232
+ dialect=normalized_dialect,
233
+ explanation=str(exc),
234
+ confidence=0.0,
235
+ suggestions=["Use a LIMIT value no greater than 1000000."],
236
+ evidence=GenerationEvidence(),
237
+ )
238
+ if re.search(
239
+ r"(?:最近|过去|last|past)\s*-\d+\s*(?:天|周|月|年|days?|weeks?|months?|years?)",
240
+ text,
241
+ re.IGNORECASE,
242
+ ):
243
+ return NL2SQLResult(
244
+ success=False,
245
+ input_text=text,
246
+ dialect=normalized_dialect,
247
+ explanation="Date interval cannot be negative",
248
+ confidence=0.0,
249
+ suggestions=["Use a non-negative date interval."],
250
+ evidence=GenerationEvidence(),
251
+ )
222
252
  dialect = normalized_dialect
223
253
  table_hint = normalized_table_hint
224
254
  if column_hints_error: