sql-dialect-master 1.1.5__tar.gz → 1.1.7__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.
- {sql_dialect_master-1.1.5/sql_dialect_master.egg-info → sql_dialect_master-1.1.7}/PKG-INFO +15 -2
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/README.md +14 -1
- sql_dialect_master-1.1.7/backend/api/client_identity.py +57 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/api/main.py +8 -1
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/api/middleware.py +43 -6
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/api/rate_limit_store.py +2 -2
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/config.py +36 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/functions_lookup.py +2 -2
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/metadata.py +2 -2
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/transpiler.py +226 -55
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/type_mapping.py +4 -4
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/pyproject.toml +1 -1
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7/sql_dialect_master.egg-info}/PKG-INFO +15 -2
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/sql_dialect_master.egg-info/SOURCES.txt +1 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/LICENSE +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/__init__.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/api/__init__.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/api/auth.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/api/readiness.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/api/workers.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/__init__.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/cache.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/column_hint_validation.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/constants.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/exceptions.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/function_call_scanner.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/function_semantics.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/functions_db.json +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/__init__.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/aggregations.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/boolean_conditions.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/core_extract.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/evidence.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/mappings.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/relations.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/templates.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/tokenizer.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_legacy.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/__init__.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/aggregations.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/clause_extract.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/dialect.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/intent.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/ir.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/models.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/orchestrator.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/predicates.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/renderer.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/resolution.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/suggestions.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/templates.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/validation.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/p1_sql_scanner.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/p2_data_validation.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/parser.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/post_processor.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/rules.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/semantic_diff.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/sql_statements.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/type_mapping.json +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/utils/__init__.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/utils/validators.py +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/setup.cfg +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/sql_dialect_master.egg-info/dependency_links.txt +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/sql_dialect_master.egg-info/requires.txt +0 -0
- {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/sql_dialect_master.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql-dialect-master
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.7
|
|
4
4
|
Summary: SQL Dialect Master - Multi-database SQL conversion engine
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -35,7 +35,7 @@ Dynamic: license-file
|
|
|
35
35
|
|
|
36
36
|
Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
|
|
37
37
|
|
|
38
|
-
Current release: 1.1.
|
|
38
|
+
Current release: 1.1.7
|
|
39
39
|
|
|
40
40
|
[](https://www.python.org/downloads/)
|
|
41
41
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -359,3 +359,16 @@ Rate limiting is on by default. The two backends are:
|
|
|
359
359
|
- `SDM_RATE_LIMIT_BACKEND=redis` — shared, atomic limits across all workers. Set `SDM_REDIS_URL` when using this backend. The store connects and pings Redis at startup and fails fast if it is unreachable; the `redis` package is required (install with `pip install redis`).
|
|
360
360
|
|
|
361
361
|
`SDM_RATE_LIMIT_ENABLED=false` disables enforcement entirely. Use the in-memory backend for local development and the Redis backend for any deployment with more than one worker or instance that must enforce a single global limit.
|
|
362
|
+
|
|
363
|
+
By default, the API does not trust `X-Forwarded-For`; rate limiting and logs
|
|
364
|
+
use the direct peer address. Set `SDM_TRUSTED_PROXIES` to the proxy IPs/CIDRs
|
|
365
|
+
only when the direct peer is a trusted proxy, after which the rightmost
|
|
366
|
+
non-trusted address in the XFF chain is used. Deployments behind a proxy must
|
|
367
|
+
also configure `SDM_HEALTH_PROBE_TOKEN` for readiness probes; readiness never
|
|
368
|
+
uses XFF for authorization.
|
|
369
|
+
|
|
370
|
+
SQL-bearing POST responses and health probes use `Cache-Control: no-store`;
|
|
371
|
+
public catalog responses use short `private` caching. Full deployment details
|
|
372
|
+
will be documented in `docs/deployment.md`.
|
|
373
|
+
|
|
374
|
+
See [`docs/deployment.md`](docs/deployment.md) for production deployment guidance.
|
|
@@ -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.
|
|
5
|
+
Current release: 1.1.7
|
|
6
6
|
|
|
7
7
|
[](https://www.python.org/downloads/)
|
|
8
8
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -326,3 +326,16 @@ Rate limiting is on by default. The two backends are:
|
|
|
326
326
|
- `SDM_RATE_LIMIT_BACKEND=redis` — shared, atomic limits across all workers. Set `SDM_REDIS_URL` when using this backend. The store connects and pings Redis at startup and fails fast if it is unreachable; the `redis` package is required (install with `pip install redis`).
|
|
327
327
|
|
|
328
328
|
`SDM_RATE_LIMIT_ENABLED=false` disables enforcement entirely. Use the in-memory backend for local development and the Redis backend for any deployment with more than one worker or instance that must enforce a single global limit.
|
|
329
|
+
|
|
330
|
+
By default, the API does not trust `X-Forwarded-For`; rate limiting and logs
|
|
331
|
+
use the direct peer address. Set `SDM_TRUSTED_PROXIES` to the proxy IPs/CIDRs
|
|
332
|
+
only when the direct peer is a trusted proxy, after which the rightmost
|
|
333
|
+
non-trusted address in the XFF chain is used. Deployments behind a proxy must
|
|
334
|
+
also configure `SDM_HEALTH_PROBE_TOKEN` for readiness probes; readiness never
|
|
335
|
+
uses XFF for authorization.
|
|
336
|
+
|
|
337
|
+
SQL-bearing POST responses and health probes use `Cache-Control: no-store`;
|
|
338
|
+
public catalog responses use short `private` caching. Full deployment details
|
|
339
|
+
will be documented in `docs/deployment.md`.
|
|
340
|
+
|
|
341
|
+
See [`docs/deployment.md`](docs/deployment.md) for production deployment guidance.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Resolve client IPs without trusting spoofable forwarding headers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
|
|
9
|
+
from fastapi import Request
|
|
10
|
+
|
|
11
|
+
_BRACKETED_IPV6 = re.compile(r"^\[([^\]]+)\](?::\d+)?$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_ip(value: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
|
15
|
+
candidate = value.strip()
|
|
16
|
+
if not candidate or "%" in candidate:
|
|
17
|
+
return None
|
|
18
|
+
bracketed = _BRACKETED_IPV6.fullmatch(candidate)
|
|
19
|
+
if bracketed:
|
|
20
|
+
candidate = bracketed.group(1)
|
|
21
|
+
elif candidate.count(":") == 1:
|
|
22
|
+
host, port = candidate.rsplit(":", 1)
|
|
23
|
+
if port.isdigit():
|
|
24
|
+
candidate = host
|
|
25
|
+
try:
|
|
26
|
+
return ipaddress.ip_address(candidate)
|
|
27
|
+
except ValueError:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_trusted(
|
|
32
|
+
address: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
|
33
|
+
trusted_networks: Iterable,
|
|
34
|
+
) -> bool:
|
|
35
|
+
return any(
|
|
36
|
+
address.version == network.version and address in network
|
|
37
|
+
for network in trusted_networks
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_client_ip(request: Request, trusted_networks: tuple = ()) -> str:
|
|
42
|
+
"""Resolve the client address, trusting XFF only behind trusted peers."""
|
|
43
|
+
peer = request.client.host if request.client else "unknown"
|
|
44
|
+
if not trusted_networks or peer == "unknown":
|
|
45
|
+
return peer
|
|
46
|
+
|
|
47
|
+
peer_address = _parse_ip(peer)
|
|
48
|
+
if peer_address is None or not _is_trusted(peer_address, trusted_networks):
|
|
49
|
+
return peer
|
|
50
|
+
|
|
51
|
+
forwarded_for = request.headers.get("X-Forwarded-For", "")
|
|
52
|
+
for value in reversed(forwarded_for.split(",")):
|
|
53
|
+
address = _parse_ip(value)
|
|
54
|
+
if address is None or _is_trusted(address, trusted_networks):
|
|
55
|
+
continue
|
|
56
|
+
return str(address)
|
|
57
|
+
return peer
|
|
@@ -235,6 +235,7 @@ app = FastAPI(
|
|
|
235
235
|
|
|
236
236
|
# Import middleware
|
|
237
237
|
from backend.api.middleware import (
|
|
238
|
+
CacheControlMiddleware,
|
|
238
239
|
RateLimiter,
|
|
239
240
|
RateLimitMiddleware,
|
|
240
241
|
SecurityHeadersMiddleware,
|
|
@@ -244,6 +245,7 @@ from backend.core.exceptions import SDMException, UnsupportedDialectError, Error
|
|
|
244
245
|
|
|
245
246
|
# Configure CORS from the centralized Pydantic settings source.
|
|
246
247
|
ALLOWED_ORIGINS = [origin.strip() for origin in settings.allowed_origins.split(",") if origin.strip()]
|
|
248
|
+
TRUSTED_PROXY_NETWORKS = settings.trusted_proxy_networks
|
|
247
249
|
|
|
248
250
|
app.add_middleware(
|
|
249
251
|
CORSMiddleware,
|
|
@@ -285,10 +287,15 @@ app.add_middleware(
|
|
|
285
287
|
RateLimitMiddleware,
|
|
286
288
|
limiter=rate_limiter,
|
|
287
289
|
enabled=settings.rate_limit_enabled,
|
|
290
|
+
trusted_proxies=TRUSTED_PROXY_NETWORKS,
|
|
288
291
|
)
|
|
289
292
|
|
|
290
293
|
# Structured request logging must wrap rate limiting so rejected requests also get IDs.
|
|
291
|
-
app.add_middleware(
|
|
294
|
+
app.add_middleware(
|
|
295
|
+
StructuredLoggingMiddleware,
|
|
296
|
+
trusted_proxies=TRUSTED_PROXY_NETWORKS,
|
|
297
|
+
)
|
|
298
|
+
app.add_middleware(CacheControlMiddleware)
|
|
292
299
|
|
|
293
300
|
# Initialize services
|
|
294
301
|
transpiler = SQLTranspiler()
|
|
@@ -19,6 +19,7 @@ from starlette.concurrency import run_in_threadpool
|
|
|
19
19
|
|
|
20
20
|
from backend.core.exceptions import ErrorCode
|
|
21
21
|
|
|
22
|
+
from .client_identity import resolve_client_ip
|
|
22
23
|
from .rate_limit_store import InMemoryRateLimitStore, RateLimitStore, create_rate_limit_store
|
|
23
24
|
from .readiness import health_probe_response
|
|
24
25
|
|
|
@@ -35,7 +36,7 @@ def _sanitize_log_value(value: str, max_length: int = 500) -> str:
|
|
|
35
36
|
class RateLimitEntry:
|
|
36
37
|
"""Legacy compatibility entry."""
|
|
37
38
|
requests: int = 0
|
|
38
|
-
window_start: float = field(default_factory=time.
|
|
39
|
+
window_start: float = field(default_factory=time.monotonic)
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
class RateLimiter:
|
|
@@ -67,13 +68,14 @@ class RateLimiter:
|
|
|
67
68
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
68
69
|
"""Rate limiting middleware with a bounded request-body guard."""
|
|
69
70
|
|
|
70
|
-
def __init__(self, app, limiter: RateLimiter = None, enabled: bool = True, max_body_bytes: int = DEFAULT_MAX_REQUEST_BODY_BYTES):
|
|
71
|
+
def __init__(self, app, limiter: RateLimiter = None, enabled: bool = True, max_body_bytes: int = DEFAULT_MAX_REQUEST_BODY_BYTES, trusted_proxies: tuple = ()):
|
|
71
72
|
super().__init__(app)
|
|
72
73
|
if max_body_bytes <= 0:
|
|
73
74
|
raise ValueError("max_body_bytes must be positive")
|
|
74
75
|
self.limiter = limiter or RateLimiter(store=None if enabled else InMemoryRateLimitStore())
|
|
75
76
|
self.enabled = enabled
|
|
76
77
|
self.max_body_bytes = max_body_bytes
|
|
78
|
+
self.trusted_proxies = trusted_proxies
|
|
77
79
|
|
|
78
80
|
async def _read_bounded_body(self, request: Request) -> Response | None:
|
|
79
81
|
"""Consume and cache request bodies without trusting Content-Length."""
|
|
@@ -171,15 +173,16 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
|
171
173
|
return response
|
|
172
174
|
|
|
173
175
|
def _get_client_id(self, request: Request) -> str:
|
|
174
|
-
return request
|
|
176
|
+
return resolve_client_ip(request, self.trusted_proxies)
|
|
175
177
|
|
|
176
178
|
|
|
177
179
|
class StructuredLoggingMiddleware(BaseHTTPMiddleware):
|
|
178
180
|
"""Structured request logging and request-id propagation."""
|
|
179
181
|
|
|
180
|
-
def __init__(self, app, log_body: bool = False):
|
|
182
|
+
def __init__(self, app, log_body: bool = False, trusted_proxies: tuple = ()):
|
|
181
183
|
super().__init__(app)
|
|
182
184
|
self.log_body = log_body
|
|
185
|
+
self.trusted_proxies = trusted_proxies
|
|
183
186
|
|
|
184
187
|
@staticmethod
|
|
185
188
|
def _get_request_id(request: Request) -> str:
|
|
@@ -346,7 +349,7 @@ class StructuredLoggingMiddleware(BaseHTTPMiddleware):
|
|
|
346
349
|
request.state.request_id = request_id
|
|
347
350
|
status_code = 500
|
|
348
351
|
error = None
|
|
349
|
-
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
|
|
352
|
+
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(resolve_client_ip(request, self.trusted_proxies)), "timestamp": datetime.now().isoformat()}
|
|
350
353
|
logger.info(json.dumps(log_data))
|
|
351
354
|
try:
|
|
352
355
|
response = await call_next(request)
|
|
@@ -370,6 +373,40 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
|
370
373
|
response = await call_next(request)
|
|
371
374
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
372
375
|
response.headers["X-Frame-Options"] = "DENY"
|
|
373
|
-
response.headers["X-XSS-Protection"] = "
|
|
376
|
+
response.headers["X-XSS-Protection"] = "0"
|
|
374
377
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
375
378
|
return response
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class CacheControlMiddleware(BaseHTTPMiddleware):
|
|
382
|
+
"""Apply explicit browser/proxy caching policy to API responses."""
|
|
383
|
+
|
|
384
|
+
@staticmethod
|
|
385
|
+
def _policy(request: Request) -> str | None:
|
|
386
|
+
if request.method == "OPTIONS":
|
|
387
|
+
return None
|
|
388
|
+
path = request.url.path
|
|
389
|
+
if path in {"/docs", "/redoc", "/openapi.json"}:
|
|
390
|
+
return None
|
|
391
|
+
if request.method == "POST" and path.startswith("/api/"):
|
|
392
|
+
return "no-store"
|
|
393
|
+
if request.method in {"GET", "HEAD"}:
|
|
394
|
+
if path in {"/health", "/ready", "/health/deep"}:
|
|
395
|
+
return "no-store"
|
|
396
|
+
if path == "/api/dialects":
|
|
397
|
+
return "private, max-age=60"
|
|
398
|
+
if path == "/api/functions" or path.startswith("/api/functions/"):
|
|
399
|
+
return "private, max-age=60"
|
|
400
|
+
if path == "/api/types" or path.startswith("/api/types/"):
|
|
401
|
+
return "private, max-age=60"
|
|
402
|
+
if path in {"/", "/api/stats"}:
|
|
403
|
+
return "private, max-age=15"
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
407
|
+
response = await call_next(request)
|
|
408
|
+
if not response.headers.get("Cache-Control"):
|
|
409
|
+
policy = self._policy(request)
|
|
410
|
+
if policy:
|
|
411
|
+
response.headers["Cache-Control"] = policy
|
|
412
|
+
return response
|
|
@@ -44,7 +44,7 @@ class InMemoryRateLimitStore:
|
|
|
44
44
|
|
|
45
45
|
def check(self, key: str, limit: int, window_seconds: int) -> tuple[bool, int, int]:
|
|
46
46
|
with self._lock:
|
|
47
|
-
now = time.
|
|
47
|
+
now = time.monotonic()
|
|
48
48
|
self._prune_expired(now)
|
|
49
49
|
count, window_start = self._entries.get(key, (0, now))
|
|
50
50
|
if now - window_start >= window_seconds:
|
|
@@ -58,7 +58,7 @@ class InMemoryRateLimitStore:
|
|
|
58
58
|
|
|
59
59
|
def stats(self) -> dict:
|
|
60
60
|
with self._lock:
|
|
61
|
-
self._prune_expired(time.
|
|
61
|
+
self._prune_expired(time.monotonic())
|
|
62
62
|
return {"backend": "memory", "active_clients": len(self._entries)}
|
|
63
63
|
|
|
64
64
|
|
|
@@ -10,6 +10,7 @@ Supports configuration via:
|
|
|
10
10
|
- Default values
|
|
11
11
|
"""
|
|
12
12
|
import logging
|
|
13
|
+
import ipaddress
|
|
13
14
|
import sys
|
|
14
15
|
from enum import Enum
|
|
15
16
|
from typing import Any, Dict, List, Literal, Optional, TypedDict
|
|
@@ -225,6 +226,7 @@ class AppSettings(BaseSettings):
|
|
|
225
226
|
"http://localhost:5174,http://127.0.0.1:5174,"
|
|
226
227
|
"http://localhost:4174,http://127.0.0.1:4174"
|
|
227
228
|
)
|
|
229
|
+
trusted_proxies: str = ""
|
|
228
230
|
max_batch_size: int = Field(100, gt=0)
|
|
229
231
|
request_timeout: int = Field(30, gt=0)
|
|
230
232
|
|
|
@@ -299,6 +301,40 @@ class AppSettings(BaseSettings):
|
|
|
299
301
|
"log_level must be one of DEBUG, INFO, WARNING, ERROR, CRITICAL"
|
|
300
302
|
)
|
|
301
303
|
return normalized
|
|
304
|
+
|
|
305
|
+
@field_validator("trusted_proxies", mode="before")
|
|
306
|
+
@classmethod
|
|
307
|
+
def _normalize_trusted_proxies(cls, value: str) -> str:
|
|
308
|
+
if value is None:
|
|
309
|
+
return ""
|
|
310
|
+
if not isinstance(value, str):
|
|
311
|
+
raise ValueError("trusted_proxies must be a comma-separated string")
|
|
312
|
+
normalized = []
|
|
313
|
+
for raw_value in value.split(","):
|
|
314
|
+
candidate = raw_value.strip()
|
|
315
|
+
if not candidate:
|
|
316
|
+
continue
|
|
317
|
+
try:
|
|
318
|
+
network = ipaddress.ip_network(candidate, strict=False)
|
|
319
|
+
except ValueError as exc:
|
|
320
|
+
raise ValueError(
|
|
321
|
+
f"trusted_proxies contains an invalid IP/CIDR: {candidate!r}"
|
|
322
|
+
) from exc
|
|
323
|
+
if network.prefixlen == 0:
|
|
324
|
+
raise ValueError("trusted_proxies must not contain a catch-all network")
|
|
325
|
+
normalized.append(str(network))
|
|
326
|
+
return ",".join(normalized)
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def trusted_proxy_networks(self) -> tuple:
|
|
330
|
+
"""Return parsed trusted proxy networks for request identity checks."""
|
|
331
|
+
if not self.trusted_proxies:
|
|
332
|
+
return ()
|
|
333
|
+
return tuple(
|
|
334
|
+
ipaddress.ip_network(value, strict=False)
|
|
335
|
+
for value in self.trusted_proxies.split(",")
|
|
336
|
+
if value
|
|
337
|
+
)
|
|
302
338
|
|
|
303
339
|
def to_dict(self) -> Dict[str, Any]:
|
|
304
340
|
"""Convert settings to dictionary."""
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"""Function Encyclopedia - Search and compare SQL functions across dialects.
|
|
3
3
|
|
|
4
4
|
Provides comprehensive SQL function lookup with:
|
|
5
|
-
-
|
|
5
|
+
- Function count defined by functions_db.json (currently 298) across 11 categories
|
|
6
6
|
- Cross-database syntax comparison for 12 dialects
|
|
7
7
|
- Fuzzy search with relevance scoring
|
|
8
8
|
- Category-based filtering
|
|
@@ -37,7 +37,7 @@ class FunctionEncyclopedia:
|
|
|
37
37
|
"""SQL Function Encyclopedia with multi-dialect comparison.
|
|
38
38
|
|
|
39
39
|
Features:
|
|
40
|
-
-
|
|
40
|
+
- Function count defined by functions_db.json (currently 298)
|
|
41
41
|
- 11 categories: string, date, math, aggregate, window, conditional, conversion, json, array, system, geo
|
|
42
42
|
- 12 database dialects support
|
|
43
43
|
- Fuzzy search with relevance scoring
|
|
@@ -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.
|
|
21
|
-
API_VERSION = "1.1.
|
|
20
|
+
PROJECT_VERSION = "1.1.7"
|
|
21
|
+
API_VERSION = "1.1.7"
|
|
22
22
|
# Frontend v2 architecture — independent of the product release version.
|
|
23
23
|
FRONTEND_ARCHITECTURE_VERSION = 2
|
|
24
24
|
|
|
@@ -214,6 +214,32 @@ def _executable_upper(sql: str, dialect: str | None = None) -> str:
|
|
|
214
214
|
return mask_non_executable(sql, dialect=dialect).upper()
|
|
215
215
|
|
|
216
216
|
|
|
217
|
+
@dataclass
|
|
218
|
+
class _ConstructEvidence:
|
|
219
|
+
"""Lazily parse one SQL view for one compatibility-notes call.
|
|
220
|
+
|
|
221
|
+
The AST is deliberately private to the evidence context. The main
|
|
222
|
+
transpilation AST is never shared with this object, so evidence checks
|
|
223
|
+
cannot observe or mutate the AST used to generate the target SQL.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
sql: str
|
|
227
|
+
dialect: str
|
|
228
|
+
masked_upper: str
|
|
229
|
+
_parsed: bool = field(default=False, init=False, repr=False)
|
|
230
|
+
_tree: Optional[exp.Expression] = field(default=None, init=False, repr=False)
|
|
231
|
+
|
|
232
|
+
def parse_one(self) -> Optional[exp.Expression]:
|
|
233
|
+
"""Return the evidence AST, parsing at most once and failing closed."""
|
|
234
|
+
if not self._parsed:
|
|
235
|
+
self._parsed = True
|
|
236
|
+
try:
|
|
237
|
+
self._tree = sqlglot.parse_one(self.sql, read=self.dialect)
|
|
238
|
+
except Exception:
|
|
239
|
+
self._tree = None
|
|
240
|
+
return self._tree
|
|
241
|
+
|
|
242
|
+
|
|
217
243
|
# Constructs whose evidence requires a FUNCTION-CALL form: the name
|
|
218
244
|
# immediately followed by ``(``, with an identifier boundary before it.
|
|
219
245
|
# A column named ``STRING_AGG_backup`` therefore never counts as a
|
|
@@ -291,7 +317,12 @@ _IDENT_END = r"(?![\w$])"
|
|
|
291
317
|
_IDENT_OR_DOT_START = r"(?<![\w$.])"
|
|
292
318
|
|
|
293
319
|
|
|
294
|
-
def _contains_type_construct(
|
|
320
|
+
def _contains_type_construct(
|
|
321
|
+
sql: str,
|
|
322
|
+
dialect: str,
|
|
323
|
+
construct: str,
|
|
324
|
+
evidence: Optional[_ConstructEvidence] = None,
|
|
325
|
+
) -> bool:
|
|
295
326
|
"""AST evidence that ``sql`` (in ``dialect``) contains the TYPE construct.
|
|
296
327
|
|
|
297
328
|
ARRAY/MAP/JSON/VARRAY claims describe type conversions, so a
|
|
@@ -304,9 +335,29 @@ def _contains_type_construct(sql: str, dialect: str, construct: str) -> bool:
|
|
|
304
335
|
* VARRAY — a USERDEFINED DataType with ``kind == "VARRAY"``.
|
|
305
336
|
Parse failures yield no evidence (fail closed).
|
|
306
337
|
"""
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
338
|
+
masked_upper = evidence.masked_upper if evidence is not None else _executable_upper(sql, dialect)
|
|
339
|
+
token_present = re.search(
|
|
340
|
+
_IDENT_START + re.escape(construct) + _IDENT_END, masked_upper
|
|
341
|
+
) is not None
|
|
342
|
+
# PostgreSQL array types use ``INTEGER[]`` rather than the ARRAY keyword.
|
|
343
|
+
# The shared masker treats square brackets as quoted-identifier syntax, so
|
|
344
|
+
# retain a narrow raw-text candidate as a prefilter; the AST check below
|
|
345
|
+
# still decides whether the brackets are an actual type declaration.
|
|
346
|
+
if construct == "ARRAY" and not token_present:
|
|
347
|
+
bracket_safe_sql = sql.translate(str.maketrans({"[": "{", "]": "}"}))
|
|
348
|
+
bracket_safe_upper = mask_non_executable(
|
|
349
|
+
bracket_safe_sql, dialect=dialect
|
|
350
|
+
).upper()
|
|
351
|
+
token_present = re.search(
|
|
352
|
+
r"\b[A-Z_][A-Z0-9_$]*\s*(?:\{\s*\d*\s*\})+",
|
|
353
|
+
bracket_safe_upper,
|
|
354
|
+
) is not None
|
|
355
|
+
if not token_present:
|
|
356
|
+
return False
|
|
357
|
+
if evidence is None:
|
|
358
|
+
evidence = _ConstructEvidence(sql, dialect, masked_upper)
|
|
359
|
+
tree = evidence.parse_one()
|
|
360
|
+
if tree is None:
|
|
310
361
|
return False
|
|
311
362
|
for node in tree.walk():
|
|
312
363
|
if not isinstance(node, exp.DataType):
|
|
@@ -320,7 +371,11 @@ def _contains_type_construct(sql: str, dialect: str, construct: str) -> bool:
|
|
|
320
371
|
|
|
321
372
|
|
|
322
373
|
def _contains_clause_construct(
|
|
323
|
-
sql: str,
|
|
374
|
+
sql: str,
|
|
375
|
+
dialect: str,
|
|
376
|
+
construct: str,
|
|
377
|
+
masked_upper: str = "",
|
|
378
|
+
evidence: Optional[_ConstructEvidence] = None,
|
|
324
379
|
) -> bool:
|
|
325
380
|
"""AST evidence that ``sql`` (in ``dialect``) contains the clause keyword.
|
|
326
381
|
|
|
@@ -348,9 +403,17 @@ def _contains_clause_construct(
|
|
|
348
403
|
(DDL column constraint).
|
|
349
404
|
Parse failures yield no evidence (fail closed).
|
|
350
405
|
"""
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
406
|
+
executable_upper = (
|
|
407
|
+
evidence.masked_upper
|
|
408
|
+
if evidence is not None
|
|
409
|
+
else masked_upper or _executable_upper(sql, dialect)
|
|
410
|
+
)
|
|
411
|
+
if re.search(_IDENT_START + re.escape(construct) + _IDENT_END, executable_upper) is None:
|
|
412
|
+
return False
|
|
413
|
+
if evidence is None:
|
|
414
|
+
evidence = _ConstructEvidence(sql, dialect, executable_upper)
|
|
415
|
+
tree = evidence.parse_one()
|
|
416
|
+
if tree is None:
|
|
354
417
|
return False
|
|
355
418
|
if construct in ("LIMIT", "TOP"):
|
|
356
419
|
limit_nodes = list(tree.find_all(exp.Limit))
|
|
@@ -404,7 +467,11 @@ def _contains_clause_construct(
|
|
|
404
467
|
|
|
405
468
|
|
|
406
469
|
def _contains_construct(
|
|
407
|
-
masked_upper: str,
|
|
470
|
+
masked_upper: str,
|
|
471
|
+
sql: str,
|
|
472
|
+
dialect: str,
|
|
473
|
+
construct: str,
|
|
474
|
+
evidence: Optional[_ConstructEvidence] = None,
|
|
408
475
|
) -> bool:
|
|
409
476
|
"""Boundary-aware construct detection on executable-masked text.
|
|
410
477
|
|
|
@@ -441,9 +508,9 @@ def _contains_construct(
|
|
|
441
508
|
return re.search(pattern, masked_upper) is not None
|
|
442
509
|
name = words[0].rstrip("()")
|
|
443
510
|
if name in _TYPE_CONSTRUCTS:
|
|
444
|
-
return _contains_type_construct(sql, dialect, name)
|
|
511
|
+
return _contains_type_construct(sql, dialect, name, evidence)
|
|
445
512
|
if name in _CLAUSE_AST_CONSTRUCTS:
|
|
446
|
-
return _contains_clause_construct(sql, dialect, name, masked_upper)
|
|
513
|
+
return _contains_clause_construct(sql, dialect, name, masked_upper, evidence)
|
|
447
514
|
if name == "APPLY":
|
|
448
515
|
return re.search(_APPLY_PATTERN, masked_upper) is not None
|
|
449
516
|
if name in _FUNCTION_FORM_CONSTRUCTS or construct.endswith("()"):
|
|
@@ -687,20 +754,65 @@ class SQLTranspiler:
|
|
|
687
754
|
ast_notes.extend(list_notes)
|
|
688
755
|
transpiled = ast.sql(dialect=target, pretty=pretty)
|
|
689
756
|
elif source == "mysql":
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
757
|
+
if target == "mysql":
|
|
758
|
+
needs_mysql_ast = False
|
|
759
|
+
else:
|
|
760
|
+
# Reuse the security mask when available. If security
|
|
761
|
+
# checks are disabled, create exactly one executable view
|
|
762
|
+
# for this branch so comments and literals cannot trigger
|
|
763
|
+
# an AST-only normalization pass.
|
|
764
|
+
mysql_executable = (
|
|
765
|
+
executable_sql
|
|
766
|
+
if executable_sql is not None
|
|
767
|
+
else mask_non_executable(sql, dialect=source)
|
|
768
|
+
)
|
|
769
|
+
# Reuse the same executable view for warning generation
|
|
770
|
+
# later in this call when security checks were disabled.
|
|
771
|
+
executable_sql = mysql_executable
|
|
772
|
+
mysql_upper = mysql_executable.upper()
|
|
773
|
+
candidate_tokens = {
|
|
774
|
+
"DATE_SUB",
|
|
775
|
+
"DATEDIFF",
|
|
776
|
+
"CONCAT",
|
|
777
|
+
"JSON_OBJECT",
|
|
778
|
+
"JSON_ARRAYAGG",
|
|
779
|
+
"ROLLUP",
|
|
780
|
+
}
|
|
781
|
+
if target == "snowflake":
|
|
782
|
+
candidate_tokens.add("DATE_FORMAT")
|
|
783
|
+
if target == "postgres":
|
|
784
|
+
candidate_tokens.add("ROUND")
|
|
785
|
+
if target == "oracle":
|
|
786
|
+
candidate_tokens.add("NOW")
|
|
787
|
+
needs_mysql_ast = any(
|
|
788
|
+
re.search(
|
|
789
|
+
_IDENT_START + re.escape(token) + _IDENT_END,
|
|
790
|
+
mysql_upper,
|
|
791
|
+
)
|
|
792
|
+
for token in candidate_tokens
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
if needs_mysql_ast:
|
|
796
|
+
ast = sqlglot.parse_one(parser_sql, read="mysql")
|
|
797
|
+
if target == "oracle":
|
|
798
|
+
ast, now_notes = self._ast_mysql_now_to_sysdate(ast, target)
|
|
799
|
+
ast_notes.extend(now_notes)
|
|
800
|
+
ast, normalization_notes, blockers = self._ast_mysql_target_normalizations(
|
|
801
|
+
ast, target
|
|
802
|
+
)
|
|
803
|
+
ast_notes.extend(normalization_notes)
|
|
804
|
+
semantic_blockers.extend(blockers)
|
|
805
|
+
if ast_notes or blockers:
|
|
806
|
+
transpiled = ast.sql(dialect=target, pretty=pretty)
|
|
807
|
+
else:
|
|
808
|
+
# Keep the canonical sqlglot path for candidate
|
|
809
|
+
# expressions that do not need a custom rewrite. This
|
|
810
|
+
# preserves the public error taxonomy for callers that
|
|
811
|
+
# replace/monitor sqlglot.transpile.
|
|
812
|
+
transpiled = sqlglot.transpile(
|
|
813
|
+
parser_sql, read=source, write=target, pretty=pretty
|
|
814
|
+
)[0]
|
|
699
815
|
else:
|
|
700
|
-
# Keep the canonical sqlglot path for ordinary MySQL
|
|
701
|
-
# conversions. This preserves the public error taxonomy
|
|
702
|
-
# for callers that replace/monitor sqlglot.transpile,
|
|
703
|
-
# while custom AST rewrites avoid a second serialization.
|
|
704
816
|
transpiled = sqlglot.transpile(
|
|
705
817
|
parser_sql, read=source, write=target, pretty=pretty
|
|
706
818
|
)[0]
|
|
@@ -1500,8 +1612,18 @@ class SQLTranspiler:
|
|
|
1500
1612
|
if not source_sql and not final_sql:
|
|
1501
1613
|
return notes
|
|
1502
1614
|
|
|
1503
|
-
|
|
1504
|
-
|
|
1615
|
+
source_evidence = _ConstructEvidence(
|
|
1616
|
+
source_sql, source, _executable_upper(source_sql, source)
|
|
1617
|
+
)
|
|
1618
|
+
final_evidence = (
|
|
1619
|
+
source_evidence
|
|
1620
|
+
if (final_sql, target) == (source_sql, source)
|
|
1621
|
+
else _ConstructEvidence(
|
|
1622
|
+
final_sql, target, _executable_upper(final_sql, target)
|
|
1623
|
+
)
|
|
1624
|
+
)
|
|
1625
|
+
source_upper = source_evidence.masked_upper
|
|
1626
|
+
final_upper = final_evidence.masked_upper
|
|
1505
1627
|
|
|
1506
1628
|
# Class B (static table): notes that claim a conversion happened.
|
|
1507
1629
|
# Marker identifies the note; the claim survives only with source
|
|
@@ -1514,7 +1636,9 @@ class SQLTranspiler:
|
|
|
1514
1636
|
for marker, source_constructs, target_constructs in _CONVERSION_NOTE_EVIDENCE:
|
|
1515
1637
|
if marker.upper() in upper_note:
|
|
1516
1638
|
if not any(
|
|
1517
|
-
_contains_construct(
|
|
1639
|
+
_contains_construct(
|
|
1640
|
+
source_upper, source_sql, source, c, source_evidence
|
|
1641
|
+
)
|
|
1518
1642
|
for c in source_constructs
|
|
1519
1643
|
):
|
|
1520
1644
|
dropped = True
|
|
@@ -1522,7 +1646,9 @@ class SQLTranspiler:
|
|
|
1522
1646
|
# An empty target tuple marks a source-construct
|
|
1523
1647
|
# advisory: no final-SQL evidence required.
|
|
1524
1648
|
if target_constructs and not any(
|
|
1525
|
-
_contains_construct(
|
|
1649
|
+
_contains_construct(
|
|
1650
|
+
final_upper, final_sql, target, c, final_evidence
|
|
1651
|
+
)
|
|
1526
1652
|
for c in target_constructs
|
|
1527
1653
|
):
|
|
1528
1654
|
dropped = True
|
|
@@ -1534,21 +1660,42 @@ class SQLTranspiler:
|
|
|
1534
1660
|
|
|
1535
1661
|
# Class A: source-construct advisories (generic, not claims of a
|
|
1536
1662
|
# completed conversion).
|
|
1537
|
-
if
|
|
1663
|
+
if (
|
|
1664
|
+
_contains_construct(source_upper, source_sql, source, "LIMIT", source_evidence)
|
|
1665
|
+
and target == "oracle"
|
|
1666
|
+
):
|
|
1538
1667
|
notes.append("Oracle uses FETCH FIRST n ROWS ONLY (12c+) or ROWNUM for LIMIT")
|
|
1539
|
-
if
|
|
1668
|
+
if (
|
|
1669
|
+
_contains_construct(
|
|
1670
|
+
source_upper, source_sql, source, "AUTO_INCREMENT", source_evidence
|
|
1671
|
+
)
|
|
1672
|
+
and target != "mysql"
|
|
1673
|
+
):
|
|
1540
1674
|
notes.append("AUTO_INCREMENT syntax varies by database")
|
|
1541
|
-
if _contains_construct(source_upper, source_sql, source, "MERGE"):
|
|
1675
|
+
if _contains_construct(source_upper, source_sql, source, "MERGE", source_evidence):
|
|
1542
1676
|
notes.append("MERGE syntax varies significantly between databases")
|
|
1543
|
-
if _contains_construct(source_upper, source_sql, source, "PIVOT") or _contains_construct(
|
|
1677
|
+
if _contains_construct(source_upper, source_sql, source, "PIVOT", source_evidence) or _contains_construct(
|
|
1678
|
+
source_upper, source_sql, source, "UNPIVOT", source_evidence
|
|
1679
|
+
):
|
|
1544
1680
|
notes.append("PIVOT/UNPIVOT syntax varies by database")
|
|
1545
1681
|
|
|
1546
1682
|
# Class B (dynamic): conversion claims, verified by the final SQL.
|
|
1547
|
-
if _contains_construct(
|
|
1548
|
-
|
|
1683
|
+
if _contains_construct(
|
|
1684
|
+
source_upper, source_sql, source, "LATERAL VIEW", source_evidence
|
|
1685
|
+
) and target not in ["hive", "spark", "databricks"]:
|
|
1686
|
+
if _contains_construct(
|
|
1687
|
+
final_upper, final_sql, target, "UNNEST", final_evidence
|
|
1688
|
+
) or _contains_construct(
|
|
1689
|
+
final_upper, final_sql, target, "JSON_TABLE", final_evidence
|
|
1690
|
+
):
|
|
1549
1691
|
notes.append("LATERAL VIEW is Hive/Spark specific, converted to UNNEST/JSON_TABLE")
|
|
1550
|
-
if
|
|
1551
|
-
|
|
1692
|
+
if (
|
|
1693
|
+
_contains_construct(source_upper, source_sql, source, "CONNECT BY", source_evidence)
|
|
1694
|
+
and target != "oracle"
|
|
1695
|
+
):
|
|
1696
|
+
if _contains_construct(
|
|
1697
|
+
final_upper, final_sql, target, "WITH RECURSIVE", final_evidence
|
|
1698
|
+
):
|
|
1552
1699
|
notes.append("CONNECT BY is Oracle specific, converted to WITH RECURSIVE")
|
|
1553
1700
|
return notes
|
|
1554
1701
|
|
|
@@ -1594,6 +1741,9 @@ class SQLTranspiler:
|
|
|
1594
1741
|
evidence the output is executable MySQL. Used only when
|
|
1595
1742
|
``target == "mysql"`` as a fail-closed guard.
|
|
1596
1743
|
"""
|
|
1744
|
+
masked_upper = mask_non_executable(sql, dialect="mysql").upper()
|
|
1745
|
+
if re.search(_IDENT_START + r"GROUP_CONCAT" + _IDENT_END, masked_upper) is None:
|
|
1746
|
+
return False
|
|
1597
1747
|
try:
|
|
1598
1748
|
tree = sqlglot.parse_one(sql, read="mysql")
|
|
1599
1749
|
except Exception:
|
|
@@ -1660,21 +1810,42 @@ class SQLTranspiler:
|
|
|
1660
1810
|
result = {"blocked": False, "reason": None, "warnings": [], "executable_sql": None}
|
|
1661
1811
|
executable_sql = mask_non_executable(sql, dialect=dialect)
|
|
1662
1812
|
result["executable_sql"] = executable_sql
|
|
1813
|
+
|
|
1814
|
+
# Keep raw and prepared parse results local to this validation call.
|
|
1815
|
+
# The read dialect is part of the cache key because the DML check has
|
|
1816
|
+
# historically used sqlglot's generic parser while dangerous/system
|
|
1817
|
+
# checks use the request dialect.
|
|
1818
|
+
parsed_cache: Dict[Tuple[str, Optional[str]], Optional[list]] = {}
|
|
1819
|
+
|
|
1820
|
+
def parse_cached(statement: str, read: str | None) -> Optional[list]:
|
|
1821
|
+
key = (statement, read)
|
|
1822
|
+
if key not in parsed_cache:
|
|
1823
|
+
try:
|
|
1824
|
+
parsed_cache[key] = (
|
|
1825
|
+
sqlglot.parse(statement, read=read)
|
|
1826
|
+
if read
|
|
1827
|
+
else sqlglot.parse(statement)
|
|
1828
|
+
)
|
|
1829
|
+
except Exception:
|
|
1830
|
+
parsed_cache[key] = None
|
|
1831
|
+
return parsed_cache[key]
|
|
1832
|
+
|
|
1663
1833
|
dangerous_operation = _DANGEROUS_OPERATION_PATTERN.search(executable_sql)
|
|
1664
1834
|
if dangerous_operation:
|
|
1665
1835
|
# A keyword can also be a perfectly legal column/alias (for
|
|
1666
1836
|
# example ``SELECT create FROM audit``). Parse only when the
|
|
1667
1837
|
# cheap lexical candidate is present and block only an actual DDL
|
|
1668
1838
|
# root node. If parsing fails, retain fail-closed behavior.
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1839
|
+
parsed = parse_cached(sql, dialect)
|
|
1840
|
+
actual_operation = (
|
|
1841
|
+
True
|
|
1842
|
+
if parsed is None
|
|
1843
|
+
else any(
|
|
1672
1844
|
isinstance(statement, _DANGEROUS_OPERATION_TYPES)
|
|
1673
1845
|
for statement in parsed
|
|
1674
1846
|
if statement is not None
|
|
1675
1847
|
)
|
|
1676
|
-
|
|
1677
|
-
actual_operation = True
|
|
1848
|
+
)
|
|
1678
1849
|
if actual_operation:
|
|
1679
1850
|
message = f"Dangerous SQL operation detected: {dangerous_operation.group(1).upper()}"
|
|
1680
1851
|
if block:
|
|
@@ -1685,8 +1856,9 @@ class SQLTranspiler:
|
|
|
1685
1856
|
if ";" not in sql or ";" not in executable_sql:
|
|
1686
1857
|
result["multiple_statements"] = False
|
|
1687
1858
|
else:
|
|
1688
|
-
|
|
1689
|
-
|
|
1859
|
+
prepared_sql = prepare_sql_for_parser(sql, dialect)
|
|
1860
|
+
parsed_statements = parse_cached(prepared_sql, dialect)
|
|
1861
|
+
if parsed_statements is not None:
|
|
1690
1862
|
result["multiple_statements"] = (
|
|
1691
1863
|
len(parsed_statements) > 1
|
|
1692
1864
|
and not _is_single_statement_stream(parsed_statements)
|
|
@@ -1698,17 +1870,19 @@ class SQLTranspiler:
|
|
|
1698
1870
|
result["reason"] = message
|
|
1699
1871
|
return result
|
|
1700
1872
|
result["warnings"].append(f"🔒 Security: {message}")
|
|
1701
|
-
|
|
1702
|
-
logger.debug("SQL statement parsing failed during security validation
|
|
1873
|
+
else:
|
|
1874
|
+
logger.debug("SQL statement parsing failed during security validation")
|
|
1703
1875
|
for pattern, message in DANGEROUS_SQL_PATTERNS:
|
|
1704
1876
|
if pattern.search(executable_sql):
|
|
1705
1877
|
if message == "System table access detected":
|
|
1706
1878
|
# Catalog words in a selected column or alias are not a
|
|
1707
1879
|
# catalog access. Inspect parsed table references and
|
|
1708
1880
|
# preserve fail-closed behavior only on parse failure.
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1881
|
+
parsed = parse_cached(sql, dialect)
|
|
1882
|
+
system_table = (
|
|
1883
|
+
True
|
|
1884
|
+
if parsed is None
|
|
1885
|
+
else any(
|
|
1712
1886
|
isinstance(table, exp.Table)
|
|
1713
1887
|
and (
|
|
1714
1888
|
table.name.lower() in _SYSTEM_CATALOG_NAMES
|
|
@@ -1719,8 +1893,7 @@ class SQLTranspiler:
|
|
|
1719
1893
|
if statement is not None
|
|
1720
1894
|
for table in statement.find_all(exp.Table)
|
|
1721
1895
|
)
|
|
1722
|
-
|
|
1723
|
-
system_table = True
|
|
1896
|
+
)
|
|
1724
1897
|
if not system_table:
|
|
1725
1898
|
continue
|
|
1726
1899
|
if block:
|
|
@@ -1732,11 +1905,9 @@ class SQLTranspiler:
|
|
|
1732
1905
|
needs_dml_ast = bool(re.search(r"\b(?:UPDATE|DELETE)\b", masked_upper))
|
|
1733
1906
|
dml_parsed = None
|
|
1734
1907
|
if needs_dml_ast and result.get("multiple_statements") is False:
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
logger.debug("DML security AST parse unavailable: %s", exc)
|
|
1739
|
-
dml_parsed = None
|
|
1908
|
+
dml_parsed = parse_cached(sql, None)
|
|
1909
|
+
if dml_parsed is None:
|
|
1910
|
+
logger.debug("DML security AST parse unavailable")
|
|
1740
1911
|
if dml_parsed is not None:
|
|
1741
1912
|
dml_without_where = set(_dml_without_where(sql, dml_parsed))
|
|
1742
1913
|
for op in sorted(dml_without_where):
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"""Type Mapper - Map SQL data types between different database dialects.
|
|
3
3
|
|
|
4
4
|
Provides comprehensive data type mapping with:
|
|
5
|
-
-
|
|
5
|
+
- Type count defined by type_mapping.json (currently 39) across 7 categories
|
|
6
6
|
- 12 database dialects support
|
|
7
7
|
- Precision and compatibility warnings
|
|
8
8
|
- Type conversion suggestions
|
|
@@ -23,11 +23,11 @@ logger = logging.getLogger(__name__)
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
class TypeMapper:
|
|
26
|
-
"""SQL Type Mapper
|
|
26
|
+
"""SQL Type Mapper backed by the type_mapping.json data file.
|
|
27
27
|
|
|
28
28
|
Features:
|
|
29
|
-
-
|
|
30
|
-
-
|
|
29
|
+
- Type count defined by type_mapping.json (currently 39)
|
|
30
|
+
- Database dialect support follows the configured dialect list
|
|
31
31
|
- Precision warnings for DECIMAL, TIMESTAMP, VARCHAR
|
|
32
32
|
- Type conversion suggestions
|
|
33
33
|
"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql-dialect-master
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.7
|
|
4
4
|
Summary: SQL Dialect Master - Multi-database SQL conversion engine
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -35,7 +35,7 @@ Dynamic: license-file
|
|
|
35
35
|
|
|
36
36
|
Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
|
|
37
37
|
|
|
38
|
-
Current release: 1.1.
|
|
38
|
+
Current release: 1.1.7
|
|
39
39
|
|
|
40
40
|
[](https://www.python.org/downloads/)
|
|
41
41
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -359,3 +359,16 @@ Rate limiting is on by default. The two backends are:
|
|
|
359
359
|
- `SDM_RATE_LIMIT_BACKEND=redis` — shared, atomic limits across all workers. Set `SDM_REDIS_URL` when using this backend. The store connects and pings Redis at startup and fails fast if it is unreachable; the `redis` package is required (install with `pip install redis`).
|
|
360
360
|
|
|
361
361
|
`SDM_RATE_LIMIT_ENABLED=false` disables enforcement entirely. Use the in-memory backend for local development and the Redis backend for any deployment with more than one worker or instance that must enforce a single global limit.
|
|
362
|
+
|
|
363
|
+
By default, the API does not trust `X-Forwarded-For`; rate limiting and logs
|
|
364
|
+
use the direct peer address. Set `SDM_TRUSTED_PROXIES` to the proxy IPs/CIDRs
|
|
365
|
+
only when the direct peer is a trusted proxy, after which the rightmost
|
|
366
|
+
non-trusted address in the XFF chain is used. Deployments behind a proxy must
|
|
367
|
+
also configure `SDM_HEALTH_PROBE_TOKEN` for readiness probes; readiness never
|
|
368
|
+
uses XFF for authorization.
|
|
369
|
+
|
|
370
|
+
SQL-bearing POST responses and health probes use `Cache-Control: no-store`;
|
|
371
|
+
public catalog responses use short `private` caching. Full deployment details
|
|
372
|
+
will be documented in `docs/deployment.md`.
|
|
373
|
+
|
|
374
|
+
See [`docs/deployment.md`](docs/deployment.md) for production deployment guidance.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/column_hint_validation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/__init__.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/aggregations.py
RENAMED
|
File without changes
|
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/core_extract.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/evidence.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/mappings.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/relations.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/templates.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/tokenizer.py
RENAMED
|
File without changes
|
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/__init__.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/aggregations.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/clause_extract.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/dialect.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/intent.py
RENAMED
|
File without changes
|
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/models.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/orchestrator.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/predicates.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/renderer.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/resolution.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/suggestions.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/templates.py
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/validation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/sql_dialect_master.egg-info/requires.txt
RENAMED
|
File without changes
|
{sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/sql_dialect_master.egg-info/top_level.txt
RENAMED
|
File without changes
|