sql-dialect-master 1.1.2__tar.gz → 1.1.4__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 (102) hide show
  1. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/PKG-INFO +30 -6
  2. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/README.md +23 -4
  3. sql_dialect_master-1.1.4/backend/api/auth.py +29 -0
  4. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/api/main.py +87 -15
  5. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/api/middleware.py +54 -17
  6. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/api/rate_limit_store.py +19 -2
  7. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/api/readiness.py +25 -7
  8. sql_dialect_master-1.1.4/backend/api/workers.py +44 -0
  9. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/cache.py +12 -2
  10. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/config.py +42 -13
  11. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/exceptions.py +3 -0
  12. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/functions_lookup.py +9 -5
  13. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/metadata.py +2 -2
  14. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_components/core_extract.py +55 -10
  15. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_components/mappings.py +2 -2
  16. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_components/templates.py +2 -2
  17. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/dialect.py +3 -1
  18. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/models.py +3 -0
  19. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/orchestrator.py +171 -6
  20. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/renderer.py +40 -5
  21. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/templates.py +16 -0
  22. sql_dialect_master-1.1.4/backend/core/nl2sql_pipeline/validation.py +77 -0
  23. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/p1_sql_scanner.py +44 -7
  24. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/parser.py +3 -2
  25. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/semantic_diff.py +3 -1
  26. sql_dialect_master-1.1.4/backend/core/sql_statements.py +30 -0
  27. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/transpiler.py +129 -34
  28. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/type_mapping.py +8 -6
  29. sql_dialect_master-1.1.4/backend/frontend_entrypoint.py +22 -0
  30. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/utils/validators.py +27 -41
  31. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/core/design_tokens.py +7 -3
  32. sql_dialect_master-1.1.4/frontend/core/fingerprints.py +24 -0
  33. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/core/navigation.py +37 -5
  34. sql_dialect_master-1.1.4/frontend/core/sql_inputs.py +6 -0
  35. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/core/state.py +125 -35
  36. sql_dialect_master-1.1.4/frontend/core/statuses.py +34 -0
  37. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/core/viewmodels.py +5 -2
  38. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/convert.py +106 -19
  39. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/diff.py +33 -5
  40. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/functions.py +2 -3
  41. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/history.py +15 -11
  42. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/lineage.py +31 -14
  43. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/nl2sql.py +32 -10
  44. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/query_analysis.py +12 -4
  45. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/runtime.py +3 -3
  46. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/templates.py +18 -1
  47. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/ui/command_palette.py +21 -2
  48. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/ui/diff.py +2 -8
  49. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/ui/editor.py +5 -1
  50. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/ui/status.py +8 -11
  51. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/pyproject.toml +17 -2
  52. sql_dialect_master-1.1.4/sdm_local_v2.py +219 -0
  53. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/sql_dialect_master.egg-info/PKG-INFO +30 -6
  54. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/sql_dialect_master.egg-info/SOURCES.txt +9 -0
  55. sql_dialect_master-1.1.4/sql_dialect_master.egg-info/entry_points.txt +2 -0
  56. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/sql_dialect_master.egg-info/requires.txt +6 -1
  57. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/sql_dialect_master.egg-info/top_level.txt +1 -0
  58. sql_dialect_master-1.1.2/backend/core/nl2sql_pipeline/validation.py +0 -28
  59. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/LICENSE +0 -0
  60. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/__init__.py +0 -0
  61. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/api/__init__.py +0 -0
  62. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/__init__.py +0 -0
  63. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/column_hint_validation.py +0 -0
  64. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/constants.py +0 -0
  65. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/function_call_scanner.py +0 -0
  66. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/function_semantics.py +0 -0
  67. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/functions_db.json +0 -0
  68. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql.py +0 -0
  69. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_components/__init__.py +0 -0
  70. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_components/aggregations.py +0 -0
  71. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_components/boolean_conditions.py +0 -0
  72. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_components/evidence.py +0 -0
  73. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_components/relations.py +0 -0
  74. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_components/tokenizer.py +0 -0
  75. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_legacy.py +0 -0
  76. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/__init__.py +0 -0
  77. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/aggregations.py +0 -0
  78. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/clause_extract.py +0 -0
  79. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/intent.py +0 -0
  80. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/ir.py +0 -0
  81. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/predicates.py +0 -0
  82. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/resolution.py +0 -0
  83. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/suggestions.py +0 -0
  84. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/p2_data_validation.py +0 -0
  85. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/post_processor.py +0 -0
  86. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/rules.py +0 -0
  87. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/type_mapping.json +0 -0
  88. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/utils/__init__.py +0 -0
  89. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/__init__.py +0 -0
  90. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/app_context_v2.py +0 -0
  91. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/core/__init__.py +0 -0
  92. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/core/escaping.py +0 -0
  93. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/core/styles.py +0 -0
  94. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/core/themes.py +0 -0
  95. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/core/type_status.py +0 -0
  96. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/__init__.py +0 -0
  97. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/settings.py +0 -0
  98. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/types.py +0 -0
  99. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/templates_v2.py +0 -0
  100. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/ui/__init__.py +0 -0
  101. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/setup.cfg +0 -0
  102. {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/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.4
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,10 +16,15 @@ 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"
22
23
  Requires-Dist: pip-audit>=2.7; extra == "qa"
24
+ Requires-Dist: build>=1.2.2; extra == "qa"
25
+ Requires-Dist: twine>=5.1; extra == "qa"
26
+ Requires-Dist: setuptools>=75.0.0; extra == "qa"
27
+ Requires-Dist: wheel>=0.45; extra == "qa"
23
28
  Provides-Extra: semantic
24
29
  Requires-Dist: duckdb>=1.5.5; extra == "semantic"
25
30
  Requires-Dist: psycopg[binary]>=3.3.5; extra == "semantic"
@@ -31,7 +36,7 @@ Dynamic: license-file
31
36
 
32
37
  Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
33
38
 
34
- Current release: 1.1.2
39
+ Current release: 1.1.4
35
40
 
36
41
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
37
42
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -77,16 +82,18 @@ cd sql-dialect-master
77
82
  python -m pip install -e .
78
83
  ```
79
84
 
80
- For development and testing, install the optional development dependencies:
85
+ For development, testing, quality checks, and local builds, use the project's
86
+ Python environment and install the optional tooling:
81
87
 
82
88
  ```bash
83
- python -m pip install -e ".[dev]"
89
+ python -m venv .venv
90
+ .venv/bin/python -m pip install -e ".[dev,qa]"
84
91
  ```
85
92
 
86
93
  For database-backed semantic tests:
87
94
 
88
95
  ```bash
89
- python -m pip install -e ".[dev,semantic]"
96
+ .venv/bin/python -m pip install -e ".[dev,semantic]"
90
97
  ```
91
98
 
92
99
  ### Run Streamlit UI (Recommended)
@@ -95,6 +102,13 @@ python -m pip install -e ".[dev,semantic]"
95
102
  streamlit run sdm_local_v2.py
96
103
  ```
97
104
 
105
+ After installing the package, the same workspace can be launched from any
106
+ working directory with:
107
+
108
+ ```bash
109
+ sdm-streamlit
110
+ ```
111
+
98
112
  Open http://localhost:8501 in your browser.
99
113
 
100
114
  ### Run FastAPI Backend
@@ -105,6 +119,16 @@ uvicorn backend.api.main:app --reload
105
119
 
106
120
  API docs available at http://localhost:8000/docs
107
121
 
122
+ Set `SDM_API_KEY` in `.env` to require credentials for `/api/*` endpoints.
123
+ Clients may send `X-API-Key` or `Authorization: Bearer <key>`. Health probes
124
+ use the separate `SDM_HEALTH_PROBE_TOKEN` setting. An empty API key preserves
125
+ local access.
126
+
127
+ `SDM_API_WORKERS` (default 4) and `SDM_API_QUEUE_SIZE` (default 4) bound CPU
128
+ work. When all slots are occupied, requests receive HTTP 503 and a
129
+ `Retry-After` header. `SDM_REQUEST_TIMEOUT` returns HTTP 504 when a request
130
+ times out; running threads retain their capacity until they finish.
131
+
108
132
  ## 📖 Usage Examples
109
133
 
110
134
  ### Python SDK
@@ -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.4
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)
@@ -48,16 +48,18 @@ cd sql-dialect-master
48
48
  python -m pip install -e .
49
49
  ```
50
50
 
51
- For development and testing, install the optional development dependencies:
51
+ For development, testing, quality checks, and local builds, use the project's
52
+ Python environment and install the optional tooling:
52
53
 
53
54
  ```bash
54
- python -m pip install -e ".[dev]"
55
+ python -m venv .venv
56
+ .venv/bin/python -m pip install -e ".[dev,qa]"
55
57
  ```
56
58
 
57
59
  For database-backed semantic tests:
58
60
 
59
61
  ```bash
60
- python -m pip install -e ".[dev,semantic]"
62
+ .venv/bin/python -m pip install -e ".[dev,semantic]"
61
63
  ```
62
64
 
63
65
  ### Run Streamlit UI (Recommended)
@@ -66,6 +68,13 @@ python -m pip install -e ".[dev,semantic]"
66
68
  streamlit run sdm_local_v2.py
67
69
  ```
68
70
 
71
+ After installing the package, the same workspace can be launched from any
72
+ working directory with:
73
+
74
+ ```bash
75
+ sdm-streamlit
76
+ ```
77
+
69
78
  Open http://localhost:8501 in your browser.
70
79
 
71
80
  ### Run FastAPI Backend
@@ -76,6 +85,16 @@ uvicorn backend.api.main:app --reload
76
85
 
77
86
  API docs available at http://localhost:8000/docs
78
87
 
88
+ Set `SDM_API_KEY` in `.env` to require credentials for `/api/*` endpoints.
89
+ Clients may send `X-API-Key` or `Authorization: Bearer <key>`. Health probes
90
+ use the separate `SDM_HEALTH_PROBE_TOKEN` setting. An empty API key preserves
91
+ local access.
92
+
93
+ `SDM_API_WORKERS` (default 4) and `SDM_API_QUEUE_SIZE` (default 4) bound CPU
94
+ work. When all slots are occupied, requests receive HTTP 503 and a
95
+ `Retry-After` header. `SDM_REQUEST_TIMEOUT` returns HTTP 504 when a request
96
+ times out; running threads retain their capacity until they finish.
97
+
79
98
  ## 📖 Usage Examples
80
99
 
81
100
  ### Python SDK
@@ -0,0 +1,29 @@
1
+ """Optional shared API-key protection for business endpoints."""
2
+ from __future__ import annotations
3
+
4
+ import secrets
5
+
6
+ from fastapi import HTTPException, Request
7
+
8
+ from backend.core.config import settings
9
+
10
+
11
+ async def require_api_access(request: Request) -> None:
12
+ """Require a configured key for /api endpoints while keeping probes separate."""
13
+ key = settings.api_key
14
+ if key is None or not request.url.path.startswith("/api/"):
15
+ return
16
+ expected = key.get_secret_value()
17
+ if not expected:
18
+ return
19
+ supplied = request.headers.get("X-API-Key", "")
20
+ if not supplied:
21
+ scheme, _, credential = request.headers.get("Authorization", "").partition(" ")
22
+ if scheme.lower() == "bearer":
23
+ supplied = credential
24
+ if not supplied or not secrets.compare_digest(supplied.encode("utf-8"), expected.encode("utf-8")):
25
+ raise HTTPException(
26
+ status_code=401,
27
+ detail="Invalid or missing API key",
28
+ headers={"WWW-Authenticate": "Bearer"},
29
+ )
@@ -11,12 +11,14 @@ 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
17
+ from contextlib import asynccontextmanager
16
18
  from datetime import datetime
17
19
  from typing import Any, Dict, List, Optional
18
20
 
19
- from fastapi import FastAPI, HTTPException, Query, Request
21
+ from fastapi import Depends, FastAPI, HTTPException, Query, Request
20
22
  from fastapi.middleware.cors import CORSMiddleware
21
23
  from fastapi.responses import JSONResponse
22
24
  from pydantic import BaseModel, ConfigDict, Field
@@ -31,10 +33,43 @@ from backend.core.transpiler import SQLTranspiler
31
33
  from backend.core.type_mapping import TypeMapper
32
34
 
33
35
  from backend.api.readiness import health_probe_response
34
-
35
- # Configure logging for the application
36
- logger = setup_logging(level=logging.INFO)
36
+ from backend.api.auth import require_api_access
37
+ from backend.api.workers import BoundedWorkerPool, WorkerCapacityError
38
+ from backend.api.rate_limit_store import InMemoryRateLimitStore
39
+
40
+ # Configure logging for the application from the validated settings object.
41
+ # Keep an INFO fallback for defensive compatibility with custom settings
42
+ # objects, while normal AppSettings values are resolved from SDM_LOG_LEVEL.
43
+ _log_level = getattr(logging, settings.log_level.upper(), logging.INFO)
44
+ logger = setup_logging(level=_log_level, log_file=settings.log_file or None)
37
45
  api_logger = logging.getLogger(__name__)
46
+ _worker_pool = BoundedWorkerPool(settings.api_workers, settings.api_queue_size)
47
+
48
+
49
+ @asynccontextmanager
50
+ async def lifespan(application: FastAPI):
51
+ """Manage the service worker pool for a normal ASGI startup/shutdown."""
52
+ pool = BoundedWorkerPool(settings.api_workers, settings.api_queue_size)
53
+ application.state.worker_pool = pool
54
+ try:
55
+ yield
56
+ finally:
57
+ pool.shutdown()
58
+ del application.state.worker_pool
59
+
60
+
61
+ async def _run_cpu_bound(func, *args):
62
+ """Run parser/transpiler work off-loop with the configured timeout."""
63
+ try:
64
+ pool = getattr(app.state, "worker_pool", _worker_pool)
65
+ return await pool.run(func, *args, timeout=settings.request_timeout)
66
+ except WorkerCapacityError as exc:
67
+ raise HTTPException(status_code=503, detail=str(exc), headers={"Retry-After": "1"}) from exc
68
+ except asyncio.TimeoutError as exc:
69
+ raise HTTPException(
70
+ status_code=504,
71
+ detail="Request processing timed out",
72
+ ) from exc
38
73
 
39
74
  # API metadata
40
75
  API_VERSION = settings.api_version
@@ -86,6 +121,8 @@ print(response.json()["target_sql"])
86
121
 
87
122
  # Initialize FastAPI app with enhanced metadata
88
123
  app = FastAPI(
124
+ dependencies=[Depends(require_api_access)],
125
+ lifespan=lifespan,
89
126
  title=API_TITLE,
90
127
  description=API_DESCRIPTION,
91
128
  version=API_VERSION,
@@ -137,7 +174,7 @@ app.add_middleware(
137
174
  allow_origins=ALLOWED_ORIGINS,
138
175
  allow_credentials=True,
139
176
  allow_methods=["GET", "POST", "OPTIONS"], # Restrict to needed methods
140
- allow_headers=["Content-Type", "Authorization", "X-Request-ID"],
177
+ allow_headers=["Content-Type", "Authorization", "X-API-Key", "X-Request-ID"],
141
178
  )
142
179
 
143
180
  # Add security headers
@@ -147,6 +184,7 @@ app.add_middleware(SecurityHeadersMiddleware)
147
184
  rate_limiter = RateLimiter(
148
185
  requests_per_window=settings.rate_limit_requests,
149
186
  window_seconds=settings.rate_limit_window,
187
+ store=None if settings.rate_limit_enabled else InMemoryRateLimitStore(),
150
188
  )
151
189
  app.add_middleware(
152
190
  RateLimitMiddleware,
@@ -173,9 +211,8 @@ async def add_process_time_header(request: Request, call_next):
173
211
  response.headers["X-API-Version"] = API_VERSION
174
212
 
175
213
  # Log request details
176
- api_logger.info(
177
- f"{request.method} {request.url.path} - {response.status_code} - {process_time:.4f}s"
178
- )
214
+ api_logger.info("%s %r - %s - %.4fs", request.method, request.url.path,
215
+ response.status_code, process_time)
179
216
  return response
180
217
 
181
218
  # Dialect info for enhanced responses (from centralized config)
@@ -199,7 +236,10 @@ class ConvertRequest(BaseModel):
199
236
  sql: str = Field(..., description="Source SQL statement to convert")
200
237
  source_dialect: str = Field(..., description="Source database dialect")
201
238
  target_dialect: str = Field(..., description="Target database dialect")
202
- pretty: bool = Field(True, description="Format output SQL with indentation")
239
+ pretty: bool = Field(
240
+ settings.transpiler_pretty_default,
241
+ description="Format output SQL with indentation",
242
+ )
203
243
 
204
244
  model_config = ConfigDict(json_schema_extra={
205
245
  "example": {
@@ -263,6 +303,20 @@ class NL2SQLResponse(BaseModel):
263
303
  explanation: str = Field("", description="Explanation of generated SQL")
264
304
  confidence: float = Field(0.0, description="Confidence score (0.0-1.0)")
265
305
  suggestions: List[str] = Field(default_factory=list, description="Improvement suggestions")
306
+ parameter_style: Optional[str] = Field(
307
+ None,
308
+ description=(
309
+ "Placeholder style for generated DML: numeric ($1 in PostgreSQL/Redshift, "
310
+ ":1 in Oracle), named (@p1 in T-SQL), format (%s in MySQL), or qmark (?)"
311
+ ),
312
+ )
313
+ parameters: List[str] = Field(
314
+ default_factory=list,
315
+ description="Ordered placeholders that callers must bind for generated DML",
316
+ )
317
+ evidence: Optional[Dict[str, Any]] = Field(
318
+ None, description="Structured evidence supporting the confidence score",
319
+ )
266
320
 
267
321
  class ParseRequest(BaseModel):
268
322
  """SQL parse request model."""
@@ -402,11 +456,14 @@ async def convert_sql(request: ConvertRequest):
402
456
  detail=f"Unsupported target dialect: {request.target_dialect}. Supported: {SUPPORTED_DIALECTS}"
403
457
  )
404
458
 
405
- result = transpiler.transpile(
459
+ # sqlglot parsing/transpilation is CPU-bound. Keep it off the event loop
460
+ # so one large request cannot stall unrelated async requests.
461
+ result = await _run_cpu_bound(
462
+ transpiler.transpile,
406
463
  request.sql,
407
464
  source_dialect,
408
465
  target_dialect,
409
- request.pretty
466
+ request.pretty,
410
467
  )
411
468
  return ConvertResponse(
412
469
  success=result.success,
@@ -431,7 +488,9 @@ async def parse_sql(request: ParseRequest):
431
488
  """
432
489
  dialect = normalize_dialect(request.dialect)
433
490
  parser = SQLParser(dialect)
434
- elements = parser.extract_elements(request.sql)
491
+ elements = await _run_cpu_bound(parser.extract_elements, request.sql)
492
+ if "error" in elements:
493
+ raise HTTPException(status_code=422, detail=elements["error"] or "SQL parsing failed")
435
494
  return {
436
495
  "success": True,
437
496
  "dialect": dialect,
@@ -455,10 +514,18 @@ async def list_functions(
455
514
  - `/api/functions?category=string` - List all string functions
456
515
  - `/api/functions?limit=100` - Get first 100 functions
457
516
  """
517
+ normalized_category = category.strip().lower() if category is not None else None
518
+ if category is not None:
519
+ if normalized_category not in func_encyclopedia.CATEGORIES:
520
+ raise HTTPException(
521
+ status_code=400,
522
+ detail=(f"Unknown function category: {category}. Available categories: "
523
+ + ", ".join(func_encyclopedia.CATEGORIES)),
524
+ )
458
525
  if search:
459
526
  functions = func_encyclopedia.search(search, limit)
460
527
  elif category:
461
- functions = func_encyclopedia.list_by_category(category)
528
+ functions = func_encyclopedia.list_by_category(normalized_category)[:limit]
462
529
  else:
463
530
  functions = func_encyclopedia.functions[:limit]
464
531
 
@@ -598,7 +665,8 @@ async def generate_sql(request: NL2SQLRequest):
598
665
  "dotted identifier"
599
666
  ),
600
667
  )
601
- result = nl2sql_generator.generate(
668
+ result = await _run_cpu_bound(
669
+ nl2sql_generator.generate,
602
670
  request.text,
603
671
  dialect,
604
672
  request.table_hint,
@@ -611,7 +679,10 @@ async def generate_sql(request: NL2SQLRequest):
611
679
  dialect=dialect,
612
680
  explanation=result.explanation,
613
681
  confidence=result.confidence,
614
- suggestions=result.suggestions
682
+ suggestions=list(result.suggestions[: settings.nl2sql_max_suggestions]),
683
+ parameter_style=result.parameter_style,
684
+ parameters=list(result.parameters),
685
+ evidence=result.evidence.to_dict() if result.evidence is not None else None,
615
686
  )
616
687
 
617
688
  @app.get("/health", tags=["system"])
@@ -678,6 +749,7 @@ async def get_stats():
678
749
  async def custom_http_exception_handler(request: Request, exc: HTTPException):
679
750
  return JSONResponse(
680
751
  status_code=exc.status_code,
752
+ headers=exc.headers,
681
753
  content={
682
754
  "success": False,
683
755
  "error": {
@@ -14,10 +14,11 @@ from typing import Dict
14
14
  from fastapi import Request, Response
15
15
  from fastapi.responses import JSONResponse
16
16
  from starlette.middleware.base import BaseHTTPMiddleware
17
+ from starlette.concurrency import run_in_threadpool
17
18
 
18
19
  from backend.core.exceptions import ErrorCode
19
20
 
20
- from .rate_limit_store import RateLimitStore, create_rate_limit_store
21
+ from .rate_limit_store import InMemoryRateLimitStore, RateLimitStore, create_rate_limit_store
21
22
  from .readiness import health_probe_response
22
23
 
23
24
  logger = logging.getLogger(__name__)
@@ -69,13 +70,17 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
69
70
  super().__init__(app)
70
71
  if max_body_bytes <= 0:
71
72
  raise ValueError("max_body_bytes must be positive")
72
- self.limiter = limiter or RateLimiter()
73
+ self.limiter = limiter or RateLimiter(store=None if enabled else InMemoryRateLimitStore())
73
74
  self.enabled = enabled
74
75
  self.max_body_bytes = max_body_bytes
75
76
 
76
77
  async def _read_bounded_body(self, request: Request) -> Response | None:
77
78
  """Consume and cache request bodies without trusting Content-Length."""
78
- if request.method in {"GET", "HEAD"}:
79
+ # HEAD has no response body by definition and some ASGI servers do
80
+ # not expose a request stream for it. GET bodies are unusual but
81
+ # still need the same bound so an oversized payload cannot bypass
82
+ # request-size protection by changing the HTTP method.
83
+ if request.method == "HEAD":
79
84
  return None
80
85
  content_length = request.headers.get("content-length")
81
86
  if content_length:
@@ -83,6 +88,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
83
88
  declared_length = int(content_length)
84
89
  except ValueError:
85
90
  return JSONResponse(status_code=400, content={"success": False, "error": {"code": 400, "message": "Invalid Content-Length"}, "timestamp": datetime.now().isoformat()})
91
+ if declared_length < 0:
92
+ return JSONResponse(status_code=400, content={"success": False, "error": {"code": 400, "message": "Invalid Content-Length"}, "timestamp": datetime.now().isoformat()})
86
93
  if declared_length > self.max_body_bytes:
87
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()})
88
95
 
@@ -97,6 +104,30 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
97
104
  return None
98
105
 
99
106
  async def dispatch(self, request: Request, call_next) -> Response:
107
+ is_health_probe = (
108
+ request.url.path in {"/health", "/", "/ready", "/health/deep"}
109
+ and request.method in {"GET", "HEAD"}
110
+ )
111
+
112
+ # Apply rate limiting before JSON/body parsing. This prevents a
113
+ # client from spending unbounded CPU on invalid payloads while still
114
+ # keeping the request-size guard active when rate limiting is disabled.
115
+ rate_limit_checked = False
116
+ remaining = reset_time = None
117
+ if self.enabled and not is_health_probe:
118
+ client_id = self._get_client_id(request)
119
+ # Redis checks use a synchronous client; a stalled store must not
120
+ # block the event loop used by unrelated liveness/API requests.
121
+ is_allowed, remaining, reset_time = await run_in_threadpool(self.limiter.is_allowed, client_id)
122
+ rate_limit_checked = True
123
+ if not is_allowed:
124
+ logger.warning("Rate limit exceeded for client: %s", _sanitize_log_value(client_id))
125
+ return JSONResponse(
126
+ status_code=429,
127
+ content={"success": False, "error": {"code": 429, "message": "Rate limit exceeded", "retry_after": reset_time}, "timestamp": datetime.now().isoformat()},
128
+ 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)},
129
+ )
130
+
100
131
  body_error = await self._read_bounded_body(request)
101
132
  if body_error is not None:
102
133
  return body_error
@@ -118,24 +149,15 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
118
149
 
119
150
  if not self.enabled:
120
151
  return await call_next(request)
121
- if request.url.path in {"/health", "/", "/ready", "/health/deep"} and request.method in {"GET", "HEAD"}:
152
+ if is_health_probe:
122
153
  if request.url.path in {"/ready", "/health/deep"}:
123
154
  return await health_probe_response(request)
124
155
  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
156
  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)
157
+ if rate_limit_checked:
158
+ response.headers["X-RateLimit-Limit"] = str(self.limiter._requests_per_window)
159
+ response.headers["X-RateLimit-Remaining"] = str(remaining)
160
+ response.headers["X-RateLimit-Reset"] = str(reset_time)
139
161
  return response
140
162
 
141
163
  def _get_client_id(self, request: Request) -> str:
@@ -167,14 +189,19 @@ class StructuredLoggingMiddleware(BaseHTTPMiddleware):
167
189
  """Normalize standard HTTP 4xx JSON responses while preserving domain-specific codes."""
168
190
  code_by_status = {
169
191
  400: ErrorCode.BAD_REQUEST.value,
192
+ 401: ErrorCode.UNAUTHORIZED.value,
170
193
  404: ErrorCode.NOT_FOUND.value,
171
194
  405: ErrorCode.METHOD_NOT_ALLOWED.value,
172
195
  413: ErrorCode.PAYLOAD_TOO_LARGE.value,
173
196
  422: ErrorCode.VALIDATION_FAILED.value,
174
197
  429: ErrorCode.RATE_LIMITED.value,
198
+ 503: ErrorCode.SERVICE_UNAVAILABLE.value,
199
+ 504: ErrorCode.REQUEST_TIMEOUT.value,
175
200
  }
176
201
  if response.status_code not in code_by_status:
177
202
  return response
203
+ if "json" not in response.headers.get("content-type", "").lower():
204
+ return response
178
205
 
179
206
  try:
180
207
  if hasattr(response, "body"):
@@ -188,6 +215,16 @@ class StructuredLoggingMiddleware(BaseHTTPMiddleware):
188
215
  except (AttributeError, UnicodeDecodeError, json.JSONDecodeError):
189
216
  return response
190
217
 
218
+ if response.status_code == 503 and isinstance(payload, dict) and "checks" in payload:
219
+ # Readiness has a public diagnostics schema of its own. Preserve
220
+ # status/checks when the service is unhealthy, including after
221
+ # consuming the downstream streaming response to decode JSON.
222
+ headers = dict(response.headers)
223
+ headers.pop("content-length", None)
224
+ headers.pop("content-type", None)
225
+ payload.setdefault("request_id", request_id)
226
+ return JSONResponse(status_code=503, content=payload, headers=headers)
227
+
191
228
  error = payload.get("error") if isinstance(payload, dict) else None
192
229
  expected_code = code_by_status[response.status_code]
193
230
  if not isinstance(error, dict):
@@ -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)