sql-dialect-master 1.1.1__tar.gz → 1.1.2__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 (93) hide show
  1. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/PKG-INFO +2 -2
  2. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/README.md +1 -1
  3. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/api/main.py +13 -1
  4. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/api/rate_limit_store.py +25 -7
  5. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/config.py +11 -2
  6. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/metadata.py +2 -2
  7. sql_dialect_master-1.1.2/backend/core/nl2sql_components/aggregations.py +52 -0
  8. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/core_extract.py +16 -5
  9. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/relations.py +99 -9
  10. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/templates.py +6 -1
  11. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/orchestrator.py +58 -10
  12. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/predicates.py +61 -1
  13. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/renderer.py +29 -19
  14. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/resolution.py +12 -3
  15. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/templates.py +39 -5
  16. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/parser.py +33 -1
  17. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/rules.py +216 -15
  18. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/transpiler.py +565 -46
  19. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/type_mapping.py +40 -3
  20. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/utils/validators.py +87 -10
  21. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/core/navigation.py +22 -0
  22. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/core/themes.py +7 -1
  23. sql_dialect_master-1.1.2/frontend/core/type_status.py +26 -0
  24. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/convert.py +1 -0
  25. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/nl2sql.py +4 -14
  26. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/templates.py +3 -1
  27. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/types.py +2 -6
  28. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/ui/editor.py +1 -1
  29. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/ui/status.py +6 -6
  30. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/pyproject.toml +1 -1
  31. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/sql_dialect_master.egg-info/PKG-INFO +2 -2
  32. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/sql_dialect_master.egg-info/SOURCES.txt +1 -0
  33. sql_dialect_master-1.1.1/backend/core/nl2sql_components/aggregations.py +0 -35
  34. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/LICENSE +0 -0
  35. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/__init__.py +0 -0
  36. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/api/__init__.py +0 -0
  37. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/api/middleware.py +0 -0
  38. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/api/readiness.py +0 -0
  39. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/__init__.py +0 -0
  40. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/cache.py +0 -0
  41. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/column_hint_validation.py +0 -0
  42. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/constants.py +0 -0
  43. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/exceptions.py +0 -0
  44. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/function_call_scanner.py +0 -0
  45. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/function_semantics.py +0 -0
  46. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/functions_db.json +0 -0
  47. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/functions_lookup.py +0 -0
  48. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql.py +0 -0
  49. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/__init__.py +0 -0
  50. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/boolean_conditions.py +0 -0
  51. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/evidence.py +0 -0
  52. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/mappings.py +0 -0
  53. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/tokenizer.py +0 -0
  54. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_legacy.py +0 -0
  55. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/__init__.py +0 -0
  56. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/aggregations.py +0 -0
  57. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/clause_extract.py +0 -0
  58. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/dialect.py +0 -0
  59. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/intent.py +0 -0
  60. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/ir.py +0 -0
  61. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/models.py +0 -0
  62. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/suggestions.py +0 -0
  63. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/validation.py +0 -0
  64. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/p1_sql_scanner.py +0 -0
  65. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/p2_data_validation.py +0 -0
  66. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/post_processor.py +0 -0
  67. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/semantic_diff.py +0 -0
  68. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/type_mapping.json +0 -0
  69. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/utils/__init__.py +0 -0
  70. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/__init__.py +0 -0
  71. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/app_context_v2.py +0 -0
  72. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/core/__init__.py +0 -0
  73. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/core/design_tokens.py +0 -0
  74. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/core/escaping.py +0 -0
  75. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/core/state.py +0 -0
  76. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/core/styles.py +0 -0
  77. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/core/viewmodels.py +0 -0
  78. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/__init__.py +0 -0
  79. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/diff.py +0 -0
  80. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/functions.py +0 -0
  81. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/history.py +0 -0
  82. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/lineage.py +0 -0
  83. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/query_analysis.py +0 -0
  84. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/runtime.py +0 -0
  85. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/settings.py +0 -0
  86. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/templates_v2.py +0 -0
  87. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/ui/__init__.py +0 -0
  88. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/ui/command_palette.py +0 -0
  89. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/ui/diff.py +0 -0
  90. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/setup.cfg +0 -0
  91. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/sql_dialect_master.egg-info/dependency_links.txt +0 -0
  92. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/sql_dialect_master.egg-info/requires.txt +0 -0
  93. {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/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.1
3
+ Version: 1.1.2
4
4
  Summary: SQL Dialect Master - Multi-database SQL conversion engine
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -31,7 +31,7 @@ Dynamic: license-file
31
31
 
32
32
  Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
33
33
 
34
- Current release: 1.1.1
34
+ Current release: 1.1.2
35
35
 
36
36
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
37
37
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -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.1
5
+ Current release: 1.1.2
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)
@@ -25,6 +25,7 @@ from backend.core.config import get_dialect_api_info, settings, setup_logging
25
25
  from backend.core import metadata as meta
26
26
  from backend.core.functions_lookup import FunctionEncyclopedia
27
27
  from backend.core.nl2sql import NL2SQLGenerator
28
+ from backend.core.nl2sql_pipeline.orchestrator import SAFE_TABLE_HINT
28
29
  from backend.core.parser import SUPPORTED_DIALECTS, SQLParser
29
30
  from backend.core.transpiler import SQLTranspiler
30
31
  from backend.core.type_mapping import TypeMapper
@@ -568,7 +569,7 @@ async def map_type(request: TypeMapRequest):
568
569
  target_dialect,
569
570
  )
570
571
  return {
571
- "success": True,
572
+ "success": result.get("success", False),
572
573
  **result,
573
574
  "timestamp": datetime.now().isoformat()
574
575
  }
@@ -586,6 +587,17 @@ async def generate_sql(request: NL2SQLRequest):
586
587
  - "Get top 10 users by score" → SELECT * FROM users ORDER BY score DESC LIMIT 10
587
588
  """
588
589
  dialect = normalize_dialect(request.dialect)
590
+ # Validate table_hint at the API boundary: an illegal identifier is
591
+ # a client error (4xx), never an internal ValueError → 500. The
592
+ # same rule backs the orchestrator's direct-caller ValueError.
593
+ if request.table_hint and not SAFE_TABLE_HINT.fullmatch(request.table_hint.strip()):
594
+ raise HTTPException(
595
+ status_code=400,
596
+ detail=(
597
+ "Invalid table_hint: must be a simple SQL identifier or "
598
+ "dotted identifier"
599
+ ),
600
+ )
589
601
  result = nl2sql_generator.generate(
590
602
  request.text,
591
603
  dialect,
@@ -5,10 +5,11 @@ Redis is opt-in. The in-memory backend remains the default for local/single-proc
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- import os
9
8
  import time
10
9
  from threading import Lock
11
- from typing import Protocol
10
+ from typing import Optional, Protocol
11
+
12
+ from backend.core.config import AppSettings, settings as _default_settings
12
13
 
13
14
 
14
15
  class RateLimitStore(Protocol):
@@ -95,11 +96,28 @@ class RedisRateLimitStore:
95
96
  return {"backend": "redis", "namespace": self._namespace}
96
97
 
97
98
 
98
- def create_rate_limit_store() -> RateLimitStore:
99
- """Create the configured store; never silently downgrade Redis to memory."""
100
- backend = os.getenv("SDM_RATE_LIMIT_BACKEND", "memory").strip().lower()
99
+ def create_rate_limit_store(config: Optional[AppSettings] = None) -> RateLimitStore:
100
+ """Create the configured store; never silently downgrade Redis to memory.
101
+
102
+ Reads configuration from ``config``, or from the unified
103
+ ``backend.core.config.settings`` singleton when ``config`` is None.
104
+ The factory never reads raw environment variables — all deployment
105
+ configuration flows through AppSettings (SDM_ prefix), so backend
106
+ selection and the Redis URL share one validated configuration path.
107
+
108
+ The Redis URL is a SecretStr in AppSettings; its actual value is
109
+ read here only to construct the Redis client, and it is never
110
+ embedded in error messages or logs.
111
+ """
112
+ settings_obj = config if config is not None else _default_settings
113
+ backend = settings_obj.rate_limit_backend
101
114
  if backend == "memory":
102
115
  return InMemoryRateLimitStore()
103
116
  if backend == "redis":
104
- return RedisRateLimitStore(os.getenv("SDM_REDIS_URL", ""))
105
- raise ValueError("Unsupported SDM_RATE_LIMIT_BACKEND; expected 'memory' or 'redis'")
117
+ url = settings_obj.redis_url
118
+ url_value = url.get_secret_value() if url is not None else ""
119
+ return RedisRateLimitStore(url_value)
120
+ # Boundary guard for untyped callers: only reachable when a caller
121
+ # passes a non-AppSettings object with an unexpected backend value
122
+ # (typed AppSettings validation rejects it earlier).
123
+ raise ValueError("Unsupported rate_limit_backend; expected 'memory' or 'redis'")
@@ -12,9 +12,9 @@ Supports configuration via:
12
12
  import logging
13
13
  import sys
14
14
  from enum import Enum
15
- from typing import Any, Dict, List, Optional, TypedDict
15
+ from typing import Any, Dict, List, Literal, Optional, TypedDict
16
16
 
17
- from pydantic import Field
17
+ from pydantic import Field, SecretStr
18
18
  from pydantic_settings import BaseSettings, SettingsConfigDict
19
19
 
20
20
  # =============================================================================
@@ -232,6 +232,15 @@ class AppSettings(BaseSettings):
232
232
  rate_limit_enabled: bool = True
233
233
  rate_limit_requests: int = 100
234
234
  rate_limit_window: int = 60
235
+ # Storage backend selection: strict lowercase values; no case or
236
+ # whitespace normalization — a Literal rejects any other spelling at
237
+ # settings construction instead of silently coercing it.
238
+ rate_limit_backend: Literal["memory", "redis"] = "memory"
239
+ # Redis connection URL, required only when the backend is redis.
240
+ # SecretStr keeps the password out of repr / logs / serialization;
241
+ # the store factory reads the actual value only to build the Redis
242
+ # client. Default None = not configured.
243
+ redis_url: Optional[SecretStr] = None
235
244
 
236
245
  # NL2SQL settings
237
246
  nl2sql_confidence_threshold: float = 0.6
@@ -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.1"
21
- API_VERSION = "1.1.1"
20
+ PROJECT_VERSION = "1.1.2"
21
+ API_VERSION = "1.1.2"
22
22
  # Frontend v2 architecture — independent of the product release version.
23
23
  FRONTEND_ARCHITECTURE_VERSION = 2
24
24
 
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env python3
2
+ """NL2SQL aggregation extraction.
3
+
4
+ Extracts aggregate function calls (COUNT, SUM, AVG, MAX, MIN) from
5
+ natural-language text using keyword matching. Uses the shared
6
+ COLUMN_PATTERNS to resolve the target column for value aggregates.
7
+ """
8
+ import re
9
+ from typing import Dict, List
10
+
11
+
12
+ def extract_aggregations(text: str, column_patterns: Dict[str, str]) -> List[str]:
13
+ """Extract aggregation expressions from text.
14
+
15
+ Returns a list of SQL aggregate expressions, e.g.
16
+ ['COUNT(*)', 'SUM(amount)']. An empty list means no aggregation
17
+ was requested.
18
+ """
19
+ aggs: List[str] = []
20
+ text_lower = text.lower()
21
+ # Pick the user's named metric column, not the first pattern that
22
+ # happens to sit inside an aggregate keyword: the legacy first-hit
23
+ # walk let "average" contain "age" and produced AVG(age).
24
+ named_metrics = [
25
+ column
26
+ for pattern, column in column_patterns.items()
27
+ if pattern != "age" and pattern in text
28
+ ]
29
+ # Bare "age" is only a metric when it is not the trailing substring
30
+ # of "average": require a non-letter before it so "average" never
31
+ # self-matches, while "average age" still does.
32
+ if re.search(r"(?<![a-z])age", text_lower):
33
+ if "age" not in named_metrics:
34
+ named_metrics.append("age")
35
+ if "price" in text_lower:
36
+ named_metrics = [c for c in named_metrics if c != "age"]
37
+ agg_column = named_metrics[0] if named_metrics else None
38
+ col = agg_column or "amount"
39
+ if any(k in text for k in ["统计", "计数", "数量", "多少", "几个", "count", "total", "how many"]):
40
+ aggs.append("COUNT(*)")
41
+ if any(k in text for k in ["求和", "总和", "合计", "总计", "sum", "total of"]):
42
+ aggs.append(f"SUM({col})")
43
+ if any(k in text_lower for k in ["平均", "均值", "平均值", "average", "avg", "mean"]):
44
+ # "average" contains "age": the average metric is price unless
45
+ # the text explicitly names the age column as the target.
46
+ col = "price" if ("price" in text_lower and not re.search(r"(?<![a-z])age", text_lower)) else (agg_column or "amount")
47
+ aggs.append(f"AVG({col})")
48
+ if any(k in text_lower for k in ["最大", "最高", "最多", "max", "maximum", "highest", "largest"]):
49
+ aggs.append(f"MAX({col})")
50
+ if any(k in text_lower for k in ["最小", "最低", "最少", "min", "minimum", "lowest", "smallest"]):
51
+ aggs.append(f"MIN({col})")
52
+ return aggs
@@ -38,18 +38,29 @@ def extract_table(text: str, table_patterns: Dict[str, str]) -> str:
38
38
  return "table_name"
39
39
 
40
40
 
41
- def extract_table_subject(text: str, table_patterns: Dict[str, str]) -> Optional[str]:
41
+ def extract_table_subject(
42
+ text: str, table_patterns: Dict[str, str], relational_context: str = ""
43
+ ) -> Optional[str]:
42
44
  """Extract the subject table (first appearing in text) for D2 relational queries.
43
45
 
44
46
  For queries like 'users who have orders', returns 'users' (the subject)
45
47
  rather than 'orders' (the relation). Uses first occurrence position.
48
+
49
+ ``relational_context`` names the relation table when the caller already
50
+ resolved one from the relational keyword set (e.g. the CN 有订单
51
+ connective maps 订单 → orders). That table must not win the subject
52
+ slot even when it is the earliest-matching pattern, so every candidate
53
+ table equal to it is excluded before the first-occurrence sort.
46
54
  """
47
55
  candidates = []
48
56
  for pattern, table in table_patterns.items():
49
- if table not in candidates:
50
- pos = text.find(pattern)
51
- if pos != -1:
52
- candidates.append((pos, table))
57
+ if relational_context and table == relational_context:
58
+ continue
59
+ pos = text.find(pattern)
60
+ if pos != -1:
61
+ if table in [t for _, t in candidates]:
62
+ continue
63
+ candidates.append((pos, table))
53
64
  if candidates:
54
65
  candidates.sort(key=lambda x: x[0])
55
66
  return candidates[0][1]
@@ -27,6 +27,8 @@ JOIN_KEY_PATTERNS: Dict[tuple, str] = {
27
27
  ("payments", "orders"): "payments.order_id = orders.id",
28
28
  ("customers", "orders"): "customers.id = orders.customer_id",
29
29
  ("orders", "customers"): "orders.customer_id = customers.id",
30
+ ("customers", "payments"): "customers.id = payments.customer_id",
31
+ ("payments", "customers"): "payments.customer_id = customers.id",
30
32
  }
31
33
 
32
34
  # Negative relational existence keywords -> NOT EXISTS
@@ -34,6 +36,8 @@ _NOT_EXISTS_KEYWORDS = [
34
36
  "without", "not have", "no ",
35
37
  "who don't have", "who doesn't have",
36
38
  "who do not have", "who does not have",
39
+ # Chinese negative relational intent: "没有任何X的" / "没有X的".
40
+ "没有任何",
37
41
  ]
38
42
 
39
43
  # Positive relational existence keywords -> EXISTS
@@ -44,6 +48,10 @@ _EXISTS_KEYWORDS = [
44
48
  "with invoices", "with payments", "with transactions",
45
49
  "with suppliers", "with products",
46
50
  "who placed", "who made", "who created", "who submitted",
51
+ # Chinese positive relational intent: "有订单的" / "有X的" phrasings.
52
+ # "有" alone is far too broad; the 2-char forms below are specific
53
+ # enough to signal a relational predicate ("users who have orders").
54
+ "有订单", "有产品", "有客户", "有员工", "有日志",
47
55
  ]
48
56
 
49
57
 
@@ -111,6 +119,18 @@ def guess_join_key(table1: str, table2: str) -> Optional[str]:
111
119
  return JOIN_KEY_PATTERNS.get((table1, table2))
112
120
 
113
121
 
122
+ # Chinese relational table connectives: the modifier word that names the
123
+ # relation table. '查询有订单的用户' → 订单 is the relation table, 用户
124
+ # is the subject. The modifier must sit between the existence keywords
125
+ # (有 / 没有任何 / 没有) and the trailing 的 / end-of-phrase, so we match
126
+ # the <modifier> token directly in the lowercased text.
127
+ _CN_REL_TABLES = {
128
+ "订单": "orders", "产品": "products", "客户": "customers",
129
+ "员工": "employees", "日志": "logs", "发票": "invoices",
130
+ "支付": "payments", "交易": "transactions",
131
+ }
132
+
133
+
114
134
  def extract_joins(
115
135
  text: str,
116
136
  table_patterns: Dict[str, str],
@@ -129,19 +149,56 @@ def extract_joins(
129
149
  joins = []
130
150
  # Collect unique tables in text-appearance order (first occurrence wins).
131
151
  # Exclude "order" when it is part of an "order by" sorting clause.
152
+ # EN word-boundary matching stays for ASCII patterns; CJK patterns have
153
+ # no word boundaries, so they match by direct occurrence — this is what
154
+ # lets '用户' / '订单' register inside a Chinese sentence.
132
155
  table_occurrences = []
156
+ text_lower = text.lower()
133
157
  for pattern, table in table_patterns.items():
134
158
  if pattern == "order" and re.search(r"\border\b\s+by\b", text, re.IGNORECASE):
135
159
  continue
136
- if re.search(r"\b" + re.escape(pattern) + r"\b", text) and table not in [t for _, t in table_occurrences]:
137
- table_occurrences.append((text.find(pattern), table))
160
+ if table in ("table", "tables", "data"):
161
+ # Generic aliases are not resolvable table names; including
162
+ # them only creates phantom multi-table requests.
163
+ continue
164
+ if pattern.isascii():
165
+ matched = re.search(r"\b" + re.escape(pattern) + r"\b", text, re.IGNORECASE)
166
+ else:
167
+ matched = re.search(re.escape(pattern), text)
168
+ if matched and table not in [t for _, t in table_occurrences]:
169
+ # Position = the pattern occurrence the match actually landed on.
170
+ # text.find() fails for ASCII patterns when case differs and for
171
+ # CJK patterns when an earlier raw occurrence exists; matched
172
+ # carries the real start index for the occurrence we used.
173
+ start = matched.start()
174
+ table_occurrences.append((start, table))
175
+ # Chinese relational connective: when 有/没有任何/没有 is followed by a
176
+ # known relation-table word, that word names the relation table even if
177
+ # the CJK pattern loop above did not catch it (e.g. the relation table
178
+ # is an EN-lexed alias). The modifier position is strictly before the
179
+ # subject table position — it is the phrase "有<relation>的<subject>"
180
+ # — so inserting it at its own position keeps tables_found ordered by
181
+ # text position and picks out the subject correctly below.
182
+ for modifier, rel_table in _CN_REL_TABLES.items():
183
+ if re.search(r"(?:有|没有任何|没有)" + re.escape(modifier), text_lower):
184
+ if rel_table not in [t for _, t in table_occurrences]:
185
+ table_occurrences.append((text_lower.find(modifier), rel_table))
186
+ break
138
187
  tables_found = [t for _, t in sorted(table_occurrences)]
139
188
  if len(tables_found) >= 2:
140
189
  join_spec = parse_join_spec(text)
141
- if any(k in text for k in _NOT_EXISTS_KEYWORDS):
190
+ cn_relational = any(
191
+ re.search(r"(?:有|没有任何|没有)" + re.escape(modifier), text)
192
+ for modifier in _CN_REL_TABLES
193
+ )
194
+ cn_negative = any(
195
+ re.search(r"(?:没有任何|没有)" + re.escape(modifier), text)
196
+ for modifier in _CN_REL_TABLES
197
+ )
198
+ if any(k in text for k in _NOT_EXISTS_KEYWORDS) or (cn_negative and cn_relational):
142
199
  join_type = "NOT EXISTS"
143
200
  relational = True
144
- elif any(k in text for k in _EXISTS_KEYWORDS):
201
+ elif any(k in text for k in _EXISTS_KEYWORDS) or (cn_relational and not cn_negative):
145
202
  join_type = "EXISTS"
146
203
  relational = True
147
204
  elif join_spec is not None:
@@ -153,8 +210,26 @@ def extract_joins(
153
210
  join_type = "JOIN"
154
211
  relational = False
155
212
 
156
- main_table = tables_found[0]
157
- for other_table in tables_found[1:]:
213
+ # Chinese relational intent (有订单的用户 etc.): the subject table is
214
+ # the one the user named last ("the users"), and the relation table is
215
+ # the one named in the 有/没有 modifier (the orders). tables_found
216
+ # is sorted by position, so the subject is the last entry, and the
217
+ # relation table is whichever was added by the CN-modifier branch.
218
+ # Detect: when the Chinese connective fired, the relation table is
219
+ # exactly the one the modifier branch appended.
220
+ cn_relation_table = None
221
+ for modifier, rel_table in _CN_REL_TABLES.items():
222
+ if re.search(r"(?:有|没有任何|没有)" + re.escape(modifier), text_lower):
223
+ cn_relation_table = rel_table
224
+ break
225
+ if cn_relation_table and len(tables_found) >= 2:
226
+ # Subject = the first-appearing table that is NOT the CN relation
227
+ # table; relation = the CN table.
228
+ subject_candidates = [t for t in tables_found if t != cn_relation_table]
229
+ main_table = subject_candidates[0] if subject_candidates else tables_found[0]
230
+ else:
231
+ main_table = tables_found[0]
232
+ for other_table in ([cn_relation_table] if cn_relation_table else [t for t in tables_found[1:]]):
158
233
  join_key = guess_join_key(main_table, other_table)
159
234
  record = {
160
235
  "type": join_type,
@@ -175,6 +250,21 @@ def extract_joins(
175
250
 
176
251
 
177
252
  def has_relational_keyword(text: str) -> bool:
178
- """Check if text contains any relational existence keyword."""
179
- all_keywords = _NOT_EXISTS_KEYWORDS + _EXISTS_KEYWORDS
180
- return any(pattern in text for pattern in all_keywords)
253
+ """Check if text contains any relational existence keyword.
254
+
255
+ CN table-modifier phrases (有订单 / 没有任何订单 …) are also relational
256
+ intent even without the EN 'have/without' phrasing. Bare EN "with <表>"
257
+ is only relational when a subject-table word also names the request:
258
+ 'find users with orders' is relational; 'find orders' alone is not.
259
+ """
260
+ all_keywords = _NOT_EXISTS_KEYWORDS + [
261
+ # 'with <表>' forms are gated in extract_joins; here we include
262
+ # them in the keyword list but let extract_joins filter by
263
+ # subject-table presence (see _resolve_relational_kind).
264
+ ] + _EXISTS_KEYWORDS
265
+ if any(pattern in text for pattern in all_keywords):
266
+ return True
267
+ for modifier in _CN_REL_TABLES:
268
+ if re.search(r"(?:有|没有任何|没有)" + re.escape(modifier), text):
269
+ return True
270
+ return False
@@ -18,6 +18,11 @@ _COMPOSITION_MARKERS = (
18
18
  "group by", "grouped by", "分组", "汇总",
19
19
  "limit", "top ", "top\t", "only ", "前",
20
20
  " and ", " or ", "且", "并且", "或者", "或",
21
+ # Chinese relational-existence phrases: capture by the enhanced path
22
+ # (EXISTS/NOT EXISTS + fail-closed), never by a single-table select
23
+ # template that would silently drop the relationship.
24
+ "有订单", "有产品", "有客户", "有员工", "有日志",
25
+ "没有任何订单", "没有任何产品", "没有任何客户",
21
26
  )
22
27
 
23
28
  _TEMPLATE_BLOCKERS = {
@@ -93,7 +98,7 @@ DEFAULT_QUERY_TEMPLATES = [
93
98
  ),
94
99
  QueryTemplate(
95
100
  name="time_range_query",
96
- pattern=r"(?:查询|获取|get|find)?\s*(?:最近|过去|last|past)\s*(\d+)\s*(天|周|月|年|days?|weeks?|months?|years?)\s*(?:的|内的)?\s*(.+)",
101
+ pattern=r"(?:查询|获取|get|find)?\s*(?:最近|过去|last|past)\s*(\d+)\s*(天|周|月|年|days?|weeks?|months?|years?)(?:的|内的)?\s*(.+)",
97
102
  sql_template="SELECT * FROM {table} WHERE {date_col} >= DATE_SUB(CURRENT_DATE, {interval})",
98
103
  priority=85,
99
104
  ),
@@ -67,6 +67,13 @@ from .validation import validate_sql as _validate_sql_impl
67
67
 
68
68
  logger = logging.getLogger(__name__)
69
69
 
70
+ # Canonical table_hint identifier rule, shared by the orchestrator (which
71
+ # raises ValueError for direct callers) and the API boundary (which
72
+ # converts the violation into a 4xx validation error before generation).
73
+ SAFE_TABLE_HINT = re.compile(
74
+ r"[A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)*"
75
+ )
76
+
70
77
 
71
78
  class PipelineNL2SQLGenerator:
72
79
  """NL2SQL pipeline base class.
@@ -180,13 +187,10 @@ class PipelineNL2SQLGenerator:
180
187
  f"Unsupported dialect: {dialect}. "
181
188
  f"Supported: {', '.join(SUPPORTED_DIALECTS)}"
182
189
  )
183
- _SAFE_TABLE_HINT = re.compile(
184
- r"[A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)*"
185
- )
186
190
  normalized_table_hint = (
187
191
  table_hint.strip() if isinstance(table_hint, str) else table_hint
188
192
  )
189
- if normalized_table_hint and not _SAFE_TABLE_HINT.fullmatch(normalized_table_hint):
193
+ if normalized_table_hint and not SAFE_TABLE_HINT.fullmatch(normalized_table_hint):
190
194
  raise ValueError(
191
195
  "table_hint must be a simple SQL identifier or dotted identifier"
192
196
  )
@@ -258,12 +262,13 @@ class PipelineNL2SQLGenerator:
258
262
  parsed = self._parse_text(text_lower, text)
259
263
  parsed.update(analysis)
260
264
  operation = self._detect_operation(text_lower)
265
+ cn_rel_table = self._cn_relation_table(text_lower)
261
266
  if self._has_relational_keyword(text_lower):
262
267
  # Relational subject: 'users who have orders where price = 5'
263
268
  # must yield FROM users, not FROM orders.
264
269
  table = (
265
270
  table_hint
266
- or self._extract_table_subject(text_lower)
271
+ or self._extract_table_subject(text_lower, cn_rel_table)
267
272
  or self._extract_table(text_lower)
268
273
  )
269
274
  else:
@@ -306,6 +311,15 @@ class PipelineNL2SQLGenerator:
306
311
  if template and self._has_relational_keyword(text_lower):
307
312
  template = None
308
313
  logger.debug("NL2SQL: Relational keyword detected, bypassing template matching")
314
+ # A select/simpler template that captures a request naming two
315
+ # tables via Chinese connectives (用户 和 发票) must route to the
316
+ # enhanced path too: the select_with_columns / simple_select
317
+ # templates would silently drop the second table's relationship
318
+ # instead of failing closed.
319
+ elif template and template.name in ("select_with_columns", "simple_select"):
320
+ if len(self._named_tables(text_lower)) >= 2:
321
+ template = None
322
+ logger.debug("NL2SQL: multi-table select captured by select template, bypassing")
309
323
  if template:
310
324
  result = self._generate_from_template(
311
325
  template, match_groups, analysis, dialect,
@@ -336,8 +350,20 @@ class PipelineNL2SQLGenerator:
336
350
  # tables is a relational/join request; it only succeeds when every
337
351
  # pair between the mentioned tables has a canonical relationship.
338
352
  named_tables = self._named_tables(text_lower)
353
+ cn_rel_table = self._cn_relation_table(text_lower)
339
354
  if len(named_tables) >= 2:
340
- table = table_hint or named_tables[0]
355
+ # Relational subject-table resolution: when the request carries
356
+ # a relational connective (D2/CN 有<relation>的<subject>), the
357
+ # outer FROM is the subject table, not the relation table the
358
+ # connective names.
359
+ table = (
360
+ table_hint
361
+ or (
362
+ self._extract_table_subject(text_lower, cn_rel_table)
363
+ if self._has_relational_keyword(text_lower)
364
+ else named_tables[0]
365
+ )
366
+ )
341
367
  unresolved = [j for j in joins if j.get("condition") is None]
342
368
  if unresolved:
343
369
  pair_desc = " ⟷ ".join(
@@ -362,8 +388,13 @@ class PipelineNL2SQLGenerator:
362
388
  ],
363
389
  )
364
390
  else:
391
+ cn_rel_table = self._cn_relation_table(text_lower)
365
392
  if self._has_relational_keyword(text_lower):
366
- table = table_hint or self._extract_table_subject(text_lower) or "table_name"
393
+ table = (
394
+ table_hint
395
+ or self._extract_table_subject(text_lower, cn_rel_table)
396
+ or "table_name"
397
+ )
367
398
  else:
368
399
  table = table_hint or self._extract_table(text_lower)
369
400
 
@@ -412,12 +443,19 @@ class PipelineNL2SQLGenerator:
412
443
  """
413
444
  return _pipeline_extract_table(text, self.TABLE_PATTERNS)
414
445
 
415
- def _extract_table_subject(self, text: str) -> Optional[str]:
446
+ def _extract_table_subject(
447
+ self, text: str, relational_context: str = ""
448
+ ) -> Optional[str]:
416
449
  """Extract the subject table (first appearing in text) for D2 relational queries.
417
450
 
418
- Delegates to ``nl2sql_pipeline.resolution.extract_table_subject``.
451
+ Delegates to ``nl2sql_pipeline.resolution.extract_table_subject``;
452
+ ``relational_context`` excludes the already-resolved relation table
453
+ from the subject slot so '查询有订单的用户' returns 'users', not
454
+ 'orders'.
419
455
  """
420
- return _pipeline_extract_table_subject(text, self.TABLE_PATTERNS)
456
+ return _pipeline_extract_table_subject(
457
+ text, self.TABLE_PATTERNS, relational_context
458
+ )
421
459
 
422
460
  def _named_tables(self, text: str) -> list:
423
461
  """Return the distinct table names the text mentions.
@@ -477,6 +515,16 @@ class PipelineNL2SQLGenerator:
477
515
  """
478
516
  return _pipeline_extract_joins(text, self.TABLE_PATTERNS)
479
517
 
518
+ def _cn_relation_table(self, text: str) -> str:
519
+ """Return the CN relational connective's table (orders, products, ...)
520
+ when present in ``text``; otherwise ""."""
521
+ import re as _re
522
+ from ..nl2sql_components.relations import _CN_REL_TABLES
523
+ for modifier, rel_table in _CN_REL_TABLES.items():
524
+ if _re.search(r"(?:有|没有任何|没有)" + _re.escape(modifier), text):
525
+ return rel_table
526
+ return ""
527
+
480
528
  def _guess_join_key(self, table1: str, table2: str) -> Optional[str]:
481
529
  """Guess the join key between two tables.
482
530
 
@@ -86,8 +86,68 @@ def extract_conditions(
86
86
  (["小于", "低于", "少于", "不足", "less", "less than", "below", "under", "<"], "<"),
87
87
  (["等于", "是", "为", "equals", "equal", "="], "="),
88
88
  ]
89
+ # For each operator word, the threshold is the number that
90
+ # immediately follows it in the original text (not numbers[0] —
91
+ # '前10条金额大于100' must bind 100 to 大于, not 10). Multi-char
92
+ # operator words are tried in longest-first order so that e.g.
93
+ # 大于等于 binds its own value before the 大于 row fires on the same
94
+ # text — the legacy row order let the shorter 大于 substring steal
95
+ # an 大于等于 match and emit ">" instead of ">=".
96
+ #
97
+ # The primary value bind (operator word → number directly after it)
98
+ # is the canonical form. When that bind fails but an operator word
99
+ # is still present (e.g. 'greater than 5' — number follows 'than',
100
+ # not 'greater'), a fallback binds the column before the operator
101
+ # word to the first number after it. This keeps the legacy
102
+ # operator-word + numbers[0] behavior for English phrases while
103
+ # correcting the Chinese case where the operator word sits between
104
+ # the column and the threshold number.
89
105
  for keywords, op in comparisons:
90
- if any(keyword in text for keyword in keywords) and numbers:
106
+ bound_value = None
107
+ matched_keyword = None
108
+ # Primary bind: the number that immediately follows the operator
109
+ # word itself (Chinese-style adjacency, e.g. '大于100').
110
+ # Longest keyword first so '大于等于' never yields to '大于'.
111
+ for keyword in sorted(keywords, key=len, reverse=True):
112
+ if keyword not in original:
113
+ continue
114
+ m = re.search(
115
+ re.escape(keyword) + r"\s*(\d+(?:\.\d+)?)",
116
+ original,
117
+ )
118
+ if m:
119
+ bound_value = m.group(1)
120
+ matched_keyword = keyword
121
+ break
122
+ # Second bind: the number at the END of the operator phrase
123
+ # (English "greater than 100" / "greater than or equal to 100"
124
+ # — the value follows "than"/"to", not the operator's head word).
125
+ # The Top-N count that precedes the whole phrase (numbers[0] =
126
+ # "10" in "top 10 …") must never win this slot.
127
+ if bound_value is None:
128
+ for keyword in sorted(keywords, key=len, reverse=True):
129
+ if keyword not in original:
130
+ continue
131
+ m = re.search(
132
+ re.escape(keyword)
133
+ + r"(?:\s+than\s+|\s+or\s+equal\s+to\s+|\s+than\s+or\s+equal\s+to\s+)?(\d+(?:\.\d+)?)",
134
+ original,
135
+ re.IGNORECASE,
136
+ )
137
+ if m:
138
+ bound_value = m.group(1)
139
+ matched_keyword = keyword
140
+ break
141
+ if matched_keyword is not None and bound_value is None and numbers:
142
+ bound_value = numbers[0]
143
+ if bound_value is not None:
144
+ col = condition_column or "column"
145
+ conditions.append(f"{col} {op} {bound_value}")
146
+ break
147
+ # Fallback: operator word present but no number is directly or
148
+ # tail-adjacent to it — bind the column named before the operator
149
+ # word to the first available number.
150
+ if any(k in original for k in keywords) and numbers:
91
151
  col = condition_column or "column"
92
152
  conditions.append(f"{col} {op} {numbers[0]}")
93
153
  break