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.
Files changed (67) hide show
  1. {sql_dialect_master-1.1.5/sql_dialect_master.egg-info → sql_dialect_master-1.1.7}/PKG-INFO +15 -2
  2. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/README.md +14 -1
  3. sql_dialect_master-1.1.7/backend/api/client_identity.py +57 -0
  4. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/api/main.py +8 -1
  5. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/api/middleware.py +43 -6
  6. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/api/rate_limit_store.py +2 -2
  7. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/config.py +36 -0
  8. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/functions_lookup.py +2 -2
  9. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/metadata.py +2 -2
  10. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/transpiler.py +226 -55
  11. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/type_mapping.py +4 -4
  12. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/pyproject.toml +1 -1
  13. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7/sql_dialect_master.egg-info}/PKG-INFO +15 -2
  14. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/sql_dialect_master.egg-info/SOURCES.txt +1 -0
  15. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/LICENSE +0 -0
  16. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/__init__.py +0 -0
  17. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/api/__init__.py +0 -0
  18. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/api/auth.py +0 -0
  19. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/api/readiness.py +0 -0
  20. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/api/workers.py +0 -0
  21. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/__init__.py +0 -0
  22. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/cache.py +0 -0
  23. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/column_hint_validation.py +0 -0
  24. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/constants.py +0 -0
  25. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/exceptions.py +0 -0
  26. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/function_call_scanner.py +0 -0
  27. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/function_semantics.py +0 -0
  28. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/functions_db.json +0 -0
  29. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql.py +0 -0
  30. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/__init__.py +0 -0
  31. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/aggregations.py +0 -0
  32. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/boolean_conditions.py +0 -0
  33. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/core_extract.py +0 -0
  34. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/evidence.py +0 -0
  35. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/mappings.py +0 -0
  36. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/relations.py +0 -0
  37. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/templates.py +0 -0
  38. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_components/tokenizer.py +0 -0
  39. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_legacy.py +0 -0
  40. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/__init__.py +0 -0
  41. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/aggregations.py +0 -0
  42. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/clause_extract.py +0 -0
  43. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/dialect.py +0 -0
  44. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/intent.py +0 -0
  45. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/ir.py +0 -0
  46. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/models.py +0 -0
  47. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/orchestrator.py +0 -0
  48. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/predicates.py +0 -0
  49. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/renderer.py +0 -0
  50. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/resolution.py +0 -0
  51. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/suggestions.py +0 -0
  52. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/templates.py +0 -0
  53. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/nl2sql_pipeline/validation.py +0 -0
  54. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/p1_sql_scanner.py +0 -0
  55. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/p2_data_validation.py +0 -0
  56. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/parser.py +0 -0
  57. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/post_processor.py +0 -0
  58. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/rules.py +0 -0
  59. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/semantic_diff.py +0 -0
  60. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/sql_statements.py +0 -0
  61. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/core/type_mapping.json +0 -0
  62. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/utils/__init__.py +0 -0
  63. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/backend/utils/validators.py +0 -0
  64. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/setup.cfg +0 -0
  65. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/sql_dialect_master.egg-info/dependency_links.txt +0 -0
  66. {sql_dialect_master-1.1.5 → sql_dialect_master-1.1.7}/sql_dialect_master.egg-info/requires.txt +0 -0
  67. {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.5
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.5
38
+ Current release: 1.1.7
39
39
 
40
40
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
41
41
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
5
+ Current release: 1.1.7
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)
@@ -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(StructuredLoggingMiddleware)
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.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.client.host if request.client else "unknown"
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.client.host if request.client else "unknown"), "timestamp": datetime.now().isoformat()}
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"] = "1; mode=block"
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.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.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
- - 50+ SQL functions across 11 categories
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
- - 50+ SQL functions
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.5"
21
- API_VERSION = "1.1.5"
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(sql: str, dialect: str, construct: str) -> bool:
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
- try:
308
- tree = sqlglot.parse_one(sql, read=dialect)
309
- except Exception:
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, dialect: str, construct: str, masked_upper: 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
- try:
352
- tree = sqlglot.parse_one(sql, read=dialect)
353
- except Exception:
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, sql: str, dialect: str, construct: 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
- ast = sqlglot.parse_one(parser_sql, read="mysql")
691
- if target == "oracle":
692
- ast, now_notes = self._ast_mysql_now_to_sysdate(ast, target)
693
- ast_notes.extend(now_notes)
694
- ast, normalization_notes, blockers = self._ast_mysql_target_normalizations(ast, target)
695
- ast_notes.extend(normalization_notes)
696
- semantic_blockers.extend(blockers)
697
- if ast_notes or blockers:
698
- transpiled = ast.sql(dialect=target, pretty=pretty)
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
- source_upper = _executable_upper(source_sql, source)
1504
- final_upper = _executable_upper(final_sql, target)
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(source_upper, source_sql, source, c)
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(final_upper, final_sql, target, c)
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 _contains_construct(source_upper, source_sql, source, "LIMIT") and target == "oracle":
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 _contains_construct(source_upper, source_sql, source, "AUTO_INCREMENT") and target != "mysql":
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(source_upper, source_sql, source, "UNPIVOT"):
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(source_upper, source_sql, source, "LATERAL VIEW") and target not in ["hive", "spark", "databricks"]:
1548
- if _contains_construct(final_upper, final_sql, target, "UNNEST") or _contains_construct(final_upper, final_sql, target, "JSON_TABLE"):
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 _contains_construct(source_upper, source_sql, source, "CONNECT BY") and target != "oracle":
1551
- if _contains_construct(final_upper, final_sql, target, "WITH RECURSIVE"):
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
- try:
1670
- parsed = sqlglot.parse(sql, read=dialect) if dialect else sqlglot.parse(sql)
1671
- actual_operation = any(
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
- except Exception:
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
- try:
1689
- parsed_statements = sqlglot.parse(prepare_sql_for_parser(sql, dialect), read=dialect) if dialect else sqlglot.parse(sql)
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
- except Exception as exc:
1702
- logger.debug("SQL statement parsing failed during security validation: %s", exc)
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
- try:
1710
- parsed = sqlglot.parse(sql, read=dialect) if dialect else sqlglot.parse(sql)
1711
- system_table = any(
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
- except Exception:
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
- try:
1736
- dml_parsed = sqlglot.parse(sql)
1737
- except Exception as exc:
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
- - 36 data types across 7 categories
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 with 36 types × 12 dialects matrix support.
26
+ """SQL Type Mapper backed by the type_mapping.json data file.
27
27
 
28
28
  Features:
29
- - 36 data types (STRING, VARCHAR, INT, DECIMAL, DATE, ARRAY, JSON, etc.)
30
- - 12 database dialects
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
  [project]
2
2
  name = "sql-dialect-master"
3
- version = "1.1.5"
3
+ version = "1.1.7"
4
4
  description = "SQL Dialect Master - Multi-database SQL conversion engine"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-dialect-master
3
- Version: 1.1.5
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.5
38
+ Current release: 1.1.7
39
39
 
40
40
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
41
41
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  backend/__init__.py
5
5
  backend/api/__init__.py
6
6
  backend/api/auth.py
7
+ backend/api/client_identity.py
7
8
  backend/api/main.py
8
9
  backend/api/middleware.py
9
10
  backend/api/rate_limit_store.py