sql-dialect-master 1.4.1__tar.gz → 1.6.0__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 (70) hide show
  1. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/PKG-INFO +2 -2
  2. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/README.md +1 -1
  3. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/metadata.py +2 -2
  4. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_pipeline/orchestrator.py +828 -13
  5. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/pyproject.toml +1 -1
  6. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/sql_dialect_master.egg-info/PKG-INFO +2 -2
  7. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/LICENSE +0 -0
  8. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/__init__.py +0 -0
  9. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/api/__init__.py +0 -0
  10. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/api/auth.py +0 -0
  11. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/api/client_identity.py +0 -0
  12. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/api/main.py +0 -0
  13. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/api/metrics.py +0 -0
  14. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/api/middleware.py +0 -0
  15. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/api/rate_limit_store.py +0 -0
  16. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/api/readiness.py +0 -0
  17. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/api/workers.py +0 -0
  18. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/__init__.py +0 -0
  19. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/cache.py +0 -0
  20. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/column_hint_validation.py +0 -0
  21. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/config.py +0 -0
  22. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/constants.py +0 -0
  23. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/exceptions.py +0 -0
  24. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/function_call_scanner.py +0 -0
  25. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/function_semantics.py +0 -0
  26. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/functions_db.json +0 -0
  27. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/functions_lookup.py +0 -0
  28. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql.py +0 -0
  29. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_components/__init__.py +0 -0
  30. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_components/aggregations.py +0 -0
  31. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_components/boolean_conditions.py +0 -0
  32. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_components/core_extract.py +0 -0
  33. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_components/evidence.py +0 -0
  34. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_components/mappings.py +0 -0
  35. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_components/relations.py +0 -0
  36. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_components/templates.py +0 -0
  37. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_components/tokenizer.py +0 -0
  38. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_legacy.py +0 -0
  39. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_llm.py +0 -0
  40. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_pipeline/__init__.py +0 -0
  41. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_pipeline/aggregations.py +0 -0
  42. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_pipeline/clause_extract.py +0 -0
  43. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_pipeline/dialect.py +0 -0
  44. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_pipeline/intent.py +0 -0
  45. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_pipeline/ir.py +0 -0
  46. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_pipeline/models.py +0 -0
  47. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_pipeline/predicates.py +0 -0
  48. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_pipeline/renderer.py +0 -0
  49. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_pipeline/resolution.py +0 -0
  50. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_pipeline/suggestions.py +0 -0
  51. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_pipeline/templates.py +0 -0
  52. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_pipeline/validation.py +0 -0
  53. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/nl2sql_schema.py +0 -0
  54. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/p1_sql_scanner.py +0 -0
  55. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/p2_data_validation.py +0 -0
  56. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/parser.py +0 -0
  57. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/post_processor.py +0 -0
  58. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/rules.py +0 -0
  59. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/semantic_diff.py +0 -0
  60. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/sql_statements.py +0 -0
  61. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/transpiler.py +0 -0
  62. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/type_mapping.json +0 -0
  63. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/core/type_mapping.py +0 -0
  64. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/utils/__init__.py +0 -0
  65. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/backend/utils/validators.py +0 -0
  66. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/setup.cfg +0 -0
  67. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/sql_dialect_master.egg-info/SOURCES.txt +0 -0
  68. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/sql_dialect_master.egg-info/dependency_links.txt +0 -0
  69. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/sql_dialect_master.egg-info/requires.txt +0 -0
  70. {sql_dialect_master-1.4.1 → sql_dialect_master-1.6.0}/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.4.1
3
+ Version: 1.6.0
4
4
  Summary: SQL Dialect Master - Multi-database SQL conversion engine
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -38,7 +38,7 @@ Dynamic: license-file
38
38
 
39
39
  Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
40
40
 
41
- Current release: 1.4.1
41
+ Current release: 1.6.0
42
42
 
43
43
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
44
44
  [![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.4.1
5
+ Current release: 1.6.0
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)
@@ -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.4.1"
21
- API_VERSION = "1.4.1"
20
+ PROJECT_VERSION = "1.6.0"
21
+ API_VERSION = "1.6.0"
22
22
  # Frontend v2 architecture — independent of the product release version.
23
23
  FRONTEND_ARCHITECTURE_VERSION = 2
24
24
 
@@ -98,6 +98,45 @@ _DATE_INTERVAL = re.compile(
98
98
  re.IGNORECASE,
99
99
  )
100
100
  MAX_NL2SQL_DATE_INTERVAL_DAYS = 36525
101
+ _SCHEMA_WHOSE_PREFIX = re.compile(
102
+ r"\bwhose\s+"
103
+ r"([A-Za-z_][A-Za-z0-9_$]*|"
104
+ r"[A-Za-z_][A-Za-z0-9_$]*\.[A-Za-z_][A-Za-z0-9_$]*|"
105
+ r"[A-Za-z_][A-Za-z0-9_$]*\.[A-Za-z_][A-Za-z0-9_$]*\.[A-Za-z_][A-Za-z0-9_$]*)\s+"
106
+ r"(is\s+not|isn't|is|equals|<>|!=|>=|<=|=|>|<)\s*",
107
+ re.IGNORECASE,
108
+ )
109
+ _SCHEMA_WHOSE_REJECTED_VALUES = {
110
+ "and", "or", "not", "is", "null", "true", "false", "unknown",
111
+ "select", "from", "where", "group", "order", "by", "limit",
112
+ "current_date", "current_timestamp", "now",
113
+ }
114
+ _SCHEMA_WHOSE_CJK_CONNECTORS = {"和", "或", "且", "并", "与", "跟", "及"}
115
+ _SCHEMA_JOINED_ON_PREFIX = re.compile(
116
+ r"\b(?:(inner|left|right)\s+)?joined\s+on\b",
117
+ re.IGNORECASE,
118
+ )
119
+ _SCHEMA_JOINED_ON_OPERAND = re.compile(
120
+ r"[A-Za-z_][A-Za-z0-9_$]*\.[A-Za-z_][A-Za-z0-9_$]*"
121
+ r"(?:\.[A-Za-z_][A-Za-z0-9_$]*)?"
122
+ )
123
+ _SCHEMA_JOINED_WITH_PREFIX = re.compile(
124
+ r"\b(?:(inner|left|right)\s+)?joined\s+with\s+"
125
+ r"([A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)?)\b",
126
+ re.IGNORECASE,
127
+ )
128
+ _SCHEMA_GROUP_LABEL_COLUMNS = {
129
+ "部门": ("department", "dept"),
130
+ "状态": ("status",),
131
+ "类型": ("type",),
132
+ "分类": ("category",),
133
+ "日期": ("date",),
134
+ "地区": ("region",),
135
+ "区域": ("region",),
136
+ "品牌": ("brand",),
137
+ "级别": ("level",),
138
+ "优先级": ("priority",),
139
+ }
101
140
 
102
141
 
103
142
  class PipelineNL2SQLGenerator:
@@ -626,14 +665,20 @@ class PipelineNL2SQLGenerator:
626
665
  return [table for _, table in sorted(found.values(), key=lambda item: item[0])]
627
666
 
628
667
  def _schema_unknown_table_patterns(
629
- self, text: str, schema: SchemaContext
668
+ self,
669
+ text: str,
670
+ schema: SchemaContext,
671
+ ignored_spans: tuple[tuple[int, int], ...] = (),
630
672
  ) -> list[str]:
631
673
  """Return mapped table names mentioned by the request but absent in schema."""
674
+ for start, end in ignored_spans:
675
+ text = text[:start] + " " * (end - start) + text[end:]
632
676
  unknown: list[str] = []
633
677
  for pattern, mapped in self.TABLE_PATTERNS.items():
634
678
  if pattern == "order" and re.search(r"\border\b\s+by\b", text, re.IGNORECASE):
635
679
  continue
636
- if not self._schema_text_match(text, pattern):
680
+ match = self._schema_text_match(text, pattern)
681
+ if not match:
637
682
  continue
638
683
  if schema.table_candidates(mapped):
639
684
  continue
@@ -642,6 +687,152 @@ class PipelineNL2SQLGenerator:
642
687
  unknown.append(mapped)
643
688
  return sorted(set(unknown))
644
689
 
690
+ def _schema_entity_counter_tables(self, text: str) -> set[str]:
691
+ """Find entity counters without treating data words as counts."""
692
+ return {
693
+ mapped for pattern, mapped in self.TABLE_PATTERNS.items()
694
+ if re.search(re.escape(pattern) + r"数(?!据|字|组)", text, re.IGNORECASE)
695
+ }
696
+
697
+ def _schema_aggregation_intent(self, text: str) -> bool:
698
+ return bool(re.search(
699
+ r"\b(?:number\s+of|count(?:\s+of)?|how\s+many|sum|average|avg|maximum|minimum|max|min)\b|"
700
+ r"统计|数量|多少|几个|总数|个数|条数|件数|行数|人数|记录数|"
701
+ r"平均|总和|汇总|最大|最小|最多|最少",
702
+ text,
703
+ re.IGNORECASE,
704
+ )) or bool(self._schema_entity_counter_tables(text))
705
+
706
+ def _schema_resolve_english_group_label(
707
+ self, text: str, schema: SchemaContext
708
+ ) -> tuple[SchemaTableContext, str, tuple[int, int]] | None:
709
+ if not self._schema_aggregation_intent(text):
710
+ return None
711
+ match = re.search(
712
+ r"\b(?:per|each)\s+([A-Za-z_][A-Za-z0-9_$]*)\b",
713
+ text,
714
+ re.IGNORECASE,
715
+ )
716
+ if match is None:
717
+ return None
718
+ label = match.group(1)
719
+ if text[match.end():].strip(" \t\r\n,.;,。;"):
720
+ raise SchemaValidationError("multiple schema group labels are unsupported")
721
+ return self._schema_resolve_group_label(
722
+ text, label, (match.start(), match.end()), schema
723
+ )
724
+
725
+ def _schema_resolve_chinese_group_label(
726
+ self, text: str, schema: SchemaContext
727
+ ) -> tuple[SchemaTableContext, str, tuple[int, int]] | None:
728
+ if not self._schema_aggregation_intent(text):
729
+ return None
730
+ match = re.search(
731
+ r"(?:每个|各个|每位)\s*([\u4e00-\u9fff]{1,8}?)"
732
+ r"(?=的|员工|订单|产品|客户|交易|支付|日志|记录|数量|用户|$)",
733
+ text,
734
+ )
735
+ if match is None:
736
+ return None
737
+ if re.search(r"每个|各个|每位", text[match.end():]):
738
+ raise SchemaValidationError("multiple schema group labels are unsupported")
739
+ return self._schema_resolve_group_label(
740
+ text, match.group(1), (match.start(), match.end()), schema
741
+ )
742
+
743
+ def _schema_resolve_group_label(
744
+ self,
745
+ text: str,
746
+ label: str,
747
+ span: tuple[int, int],
748
+ schema: SchemaContext,
749
+ ) -> tuple[SchemaTableContext, str, tuple[int, int]]:
750
+ """Resolve one consumed label without treating its table as a JOIN."""
751
+ masked = text[:span[0]] + " " * (span[1] - span[0]) + text[span[1]:]
752
+ mentioned = self._schema_table_mentions(masked, schema)
753
+ if len(mentioned) != 1:
754
+ raise SchemaValidationError("schema group label subject is ambiguous")
755
+ data_table = mentioned[0]
756
+ column_names = (label, *_SCHEMA_GROUP_LABEL_COLUMNS.get(label, ()))
757
+ column_keys = {name.casefold() for name in column_names}
758
+ direct_columns = {
759
+ column for column in data_table.columns
760
+ if column.casefold() in column_keys
761
+ }
762
+ if len(direct_columns) > 1:
763
+ raise SchemaValidationError("schema group label column is ambiguous")
764
+ if direct_columns:
765
+ return data_table, direct_columns.pop(), span
766
+
767
+ label_tables = schema.table_candidates(label)
768
+ if not label_tables:
769
+ mapped_labels = [
770
+ mapped for pattern, mapped in self.TABLE_PATTERNS.items()
771
+ if pattern.casefold() == label.casefold()
772
+ ]
773
+ candidates = [
774
+ candidate
775
+ for mapped in mapped_labels
776
+ for candidate in schema.table_candidates(mapped)
777
+ ]
778
+ label_tables = tuple({candidate.key: candidate for candidate in candidates}.values())
779
+ if len(label_tables) != 1:
780
+ raise SchemaValidationError("schema group label is not a unique table")
781
+ label_table = label_tables[0]
782
+ if label_table.key == data_table.key:
783
+ raise SchemaValidationError("schema group label cannot be the subject table")
784
+
785
+ # Only an explicitly declared data-table -> label-table relationship
786
+ # proves that the data table carries the grouping foreign key. The
787
+ # relationship index also exposes synthetic reverse entries for join
788
+ # resolution, which must not be used here: accepting one would make a
789
+ # request such as "users per order" group users by their primary key.
790
+ relationships = tuple(
791
+ relationship
792
+ for relationship in schema.relationships
793
+ if relationship.from_table.casefold() == data_table.key
794
+ and relationship.to_table.casefold() == label_table.key
795
+ )
796
+ if len(relationships) == 1:
797
+ label_singular = (
798
+ label[:-1] if label.casefold().endswith("s") else label
799
+ )
800
+ table_basename = label_table.name.rsplit(".", 1)[-1]
801
+ table_singular = (
802
+ table_basename[:-1]
803
+ if table_basename.casefold().endswith("s")
804
+ else table_basename
805
+ )
806
+ allowed_foreign_keys = {
807
+ f"{label_singular}_id".casefold(),
808
+ f"{table_singular}_id".casefold(),
809
+ f"{table_basename}_id".casefold(),
810
+ }
811
+ from_column = relationships[0].from_column
812
+ if from_column.casefold() not in allowed_foreign_keys:
813
+ raise SchemaValidationError(
814
+ "schema group label relationship has an unsupported foreign key"
815
+ )
816
+ return data_table, from_column, span
817
+ if len(relationships) > 1:
818
+ raise SchemaValidationError("schema group label relationship is ambiguous")
819
+
820
+ table_basename = label_table.name.rsplit(".", 1)[-1]
821
+ singular = (
822
+ table_basename[:-1]
823
+ if table_basename.casefold().endswith("s")
824
+ else table_basename
825
+ )
826
+ foreign_keys = [
827
+ column for column in data_table.columns
828
+ if column.casefold() == (singular + "_id").casefold()
829
+ ]
830
+ if len(foreign_keys) > 1:
831
+ raise SchemaValidationError("schema group label foreign key is ambiguous")
832
+ if foreign_keys:
833
+ return data_table, foreign_keys[0], span
834
+ raise SchemaValidationError("schema group label relationship is missing")
835
+
645
836
  def _schema_resolve_table(
646
837
  self,
647
838
  text: str,
@@ -668,6 +859,7 @@ class PipelineNL2SQLGenerator:
668
859
  table: SchemaTableContext,
669
860
  column_hints: Optional[List[str]],
670
861
  schema: SchemaContext,
862
+ suppress_quantity_token: bool = False,
671
863
  ) -> list[str]:
672
864
  if column_hints:
673
865
  resolved: list[str] = []
@@ -689,10 +881,63 @@ class PipelineNL2SQLGenerator:
689
881
  )
690
882
  return resolved
691
883
 
692
- columns = self._extract_columns(text)
884
+ projection_source = text
885
+ if suppress_quantity_token:
886
+ projection_source = re.sub(r"数量", " " * len("数量"), projection_source)
887
+ projection_text = mask_non_executable(self._schema_projection_text(projection_source))
888
+ candidates: list[tuple[int, int, str]] = []
889
+ column_names = list(table.columns)
890
+ for column in column_names:
891
+ variants = [column]
892
+ if not column.casefold().endswith("s"):
893
+ variants.append(column + "s")
894
+ for variant in variants:
895
+ pattern = re.escape(variant)
896
+ if not re.search(r"[\u4e00-\u9fff]", variant):
897
+ pattern = rf"(?<![\w$]){pattern}(?![\w$])"
898
+ for match in re.finditer(pattern, projection_text, re.IGNORECASE):
899
+ candidates.append((match.start(), match.end(), column))
900
+ qualified = re.escape(table.name) + r"\." + re.escape(variant)
901
+ for match in re.finditer(qualified, projection_text, re.IGNORECASE):
902
+ candidates.append((match.start(), match.end(), column))
903
+
904
+ mapped_columns = self._extract_columns(projection_text)
905
+ if mapped_columns != ["*"]:
906
+ for mapped_column in mapped_columns:
907
+ canonical = schema.canonical_column(table, mapped_column)
908
+ if canonical is None:
909
+ raise SchemaValidationError("mapped projection column is outside the schema")
910
+ aliases = [
911
+ alias for alias, value in self.COLUMN_PATTERNS.items()
912
+ if value.casefold() == mapped_column.casefold()
913
+ ] + [canonical]
914
+ for alias in aliases:
915
+ pattern = re.escape(alias)
916
+ if not re.search(r"[\u4e00-\u9fff]", alias):
917
+ pattern = rf"(?<![\w$]){pattern}(?![\w$])"
918
+ for match in re.finditer(pattern, projection_text, re.IGNORECASE):
919
+ candidates.append((match.start(), match.end(), canonical))
920
+
921
+ selected: list[tuple[int, int, str]] = []
922
+ for candidate in sorted(candidates, key=lambda item: (item[0], -(item[1] - item[0]))):
923
+ if any(candidate[0] < existing[1] and existing[0] < candidate[1] for existing in selected):
924
+ continue
925
+ selected.append(candidate)
926
+ if selected:
927
+ deduplicated: list[str] = []
928
+ seen_columns: set[str] = set()
929
+ for _, _, column in selected:
930
+ key = column.casefold()
931
+ if key in seen_columns:
932
+ continue
933
+ seen_columns.add(key)
934
+ deduplicated.append(column)
935
+ return deduplicated
936
+
937
+ columns = self._extract_columns(projection_text)
693
938
  if columns == ["*"]:
694
939
  for candidate in table.columns:
695
- if self._schema_text_match(text, candidate):
940
+ if self._schema_text_match(projection_text, candidate):
696
941
  columns.append(candidate)
697
942
  if len(columns) > 1:
698
943
  columns.remove("*")
@@ -709,6 +954,187 @@ class PipelineNL2SQLGenerator:
709
954
  resolved.append(canonical)
710
955
  return resolved
711
956
 
957
+ @staticmethod
958
+ def _schema_projection_text(text: str) -> str:
959
+ """Mask explicit ordering clauses so their columns are not projected."""
960
+ match = re.search(
961
+ r"\b(?:order(?:ed)?|sort(?:ed)?)\s+by\b",
962
+ text,
963
+ re.IGNORECASE,
964
+ )
965
+ if match is None:
966
+ return text
967
+ return text[:match.start()] + " " * (len(text) - match.start())
968
+
969
+ def _schema_resolve_ordering(
970
+ self,
971
+ text: str,
972
+ table: SchemaTableContext,
973
+ schema: SchemaContext,
974
+ ) -> Optional[Tuple[str, str]]:
975
+ """Resolve one explicit schema-aware ORDER BY target."""
976
+ match = re.search(
977
+ r"\b(?:order(?:ed)?|sort(?:ed)?)\s+by\s+"
978
+ r"([A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)?)"
979
+ r"(.*)$",
980
+ text,
981
+ re.IGNORECASE,
982
+ )
983
+ if match is None:
984
+ return self._extract_ordering(text)
985
+ raw_column, tail = match.groups()
986
+ direction = "ASC"
987
+ direction_match = re.match(
988
+ r"\s+(ascending|descending|asc|desc|升序|降序)\b",
989
+ tail,
990
+ re.IGNORECASE,
991
+ )
992
+ if direction_match:
993
+ direction = "DESC" if direction_match.group(1).casefold() in {"descending", "desc", "降序"} else "ASC"
994
+ tail = tail[direction_match.end():]
995
+ if tail.strip(" \t\r\n,.;,。;"):
996
+ raise SchemaValidationError("multiple schema-aware ordering columns are unsupported")
997
+ parts = raw_column.split(".")
998
+ column_name = parts[-1]
999
+ if len(parts) > 1:
1000
+ candidates = schema.table_candidates(".".join(parts[:-1]))
1001
+ if len(candidates) != 1 or candidates[0].key != table.key:
1002
+ raise SchemaValidationError("ordering column table is not the selected schema table")
1003
+ canonical = schema.canonical_column(table, column_name)
1004
+ if canonical is None:
1005
+ raise SchemaValidationError("ordering column is outside the schema")
1006
+ return canonical, direction
1007
+
1008
+ def _schema_resolve_count_metric(
1009
+ self,
1010
+ text: str,
1011
+ table: SchemaTableContext,
1012
+ schema: SchemaContext,
1013
+ ) -> tuple[str, str | None] | None:
1014
+ count_intent = bool(re.search(
1015
+ r"统计|数量|多少|几个|总数|个数|条数|件数|行数|人数|记录数|"
1016
+ r"\bnumber\s+of\b|\bcount(?:\s+of)?\b|\bhow\s+many\b",
1017
+ text,
1018
+ re.IGNORECASE,
1019
+ ))
1020
+ if not count_intent:
1021
+ for mapped in self._schema_entity_counter_tables(text):
1022
+ candidates = schema.table_candidates(mapped)
1023
+ if len(candidates) == 1 and candidates[0].key == table.key:
1024
+ count_intent = True
1025
+ break
1026
+ measure_table: str | None = None
1027
+ if "库存数量" in text:
1028
+ measure_table = "inventory"
1029
+ elif "购买数量" in text:
1030
+ measure_table = "orders"
1031
+ if measure_table is not None:
1032
+ if table.name.rsplit(".", 1)[-1].casefold() != measure_table:
1033
+ raise SchemaValidationError("quantity measure table is not selected")
1034
+ quantity = schema.canonical_column(table, "quantity")
1035
+ if quantity is None:
1036
+ raise SchemaValidationError("quantity measure is outside the schema")
1037
+ return "sum", quantity
1038
+ if count_intent:
1039
+ return "count", None
1040
+ return None
1041
+
1042
+ def _schema_extract_aggregations(
1043
+ self,
1044
+ text: str,
1045
+ table: SchemaTableContext | None = None,
1046
+ schema: SchemaContext | None = None,
1047
+ count_metric: tuple[str, str | None] | None = None,
1048
+ ) -> list[str]:
1049
+ existing = self._extract_aggregations(text)
1050
+ if count_metric is not None and count_metric[0] == "sum":
1051
+ return [f"SUM({count_metric[1]})"]
1052
+ if count_metric is not None and count_metric[0] == "count" and not any(
1053
+ aggregation.upper().startswith("COUNT(") for aggregation in existing
1054
+ ):
1055
+ existing = [*existing, "COUNT(*) AS count"]
1056
+ if table is None or schema is None or not table.has_column_metadata:
1057
+ return existing
1058
+
1059
+ group_match = re.search(r"\b(?:group(?:ed)?\s+by|per|each|by)\b", text, re.IGNORECASE)
1060
+ metric_text = text if group_match is None else text[:group_match.start()]
1061
+ metric_candidates: list[tuple[int, str]] = []
1062
+ for column in table.columns:
1063
+ pattern = rf"(?<![\w$]){re.escape(column)}(?![\w$])"
1064
+ match = re.search(pattern, metric_text, re.IGNORECASE)
1065
+ if match:
1066
+ metric_candidates.append((match.start(), column))
1067
+ if metric_candidates:
1068
+ metric = min(metric_candidates, key=lambda item: item[0])[1]
1069
+ existing = [
1070
+ re.sub(r"^(AVG|SUM|MAX|MIN)\([^)]*\)$", lambda m: f"{m.group(1)}({metric})", aggregation, flags=re.IGNORECASE)
1071
+ if re.match(r"^(AVG|SUM|MAX|MIN)\(", aggregation, re.IGNORECASE)
1072
+ else aggregation
1073
+ for aggregation in existing
1074
+ ]
1075
+ return existing
1076
+
1077
+ def _schema_resolve_group_by(
1078
+ self,
1079
+ text: str,
1080
+ table: SchemaTableContext,
1081
+ schema: SchemaContext,
1082
+ aggregations: list[str],
1083
+ ) -> list[str]:
1084
+ lowered = text.lower()
1085
+ match = re.search(
1086
+ r"\b(?:group\s+by|grouped\s+by)\s+"
1087
+ r"([A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)?)(.*)$",
1088
+ lowered,
1089
+ re.IGNORECASE,
1090
+ )
1091
+ match_kind = "group"
1092
+ if match is None and aggregations:
1093
+ if not re.search(r"\b(?:ordered|sorted|order|sort)\s+by\b", lowered):
1094
+ match = re.search(
1095
+ r"\b(?:per|each|by)\s+"
1096
+ r"([A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)?)(.*)$",
1097
+ lowered,
1098
+ re.IGNORECASE,
1099
+ )
1100
+ match_kind = "aggregation-group"
1101
+ chinese_group_intent = bool(re.search(r"每个|各个|每位|分组|汇总|按[^\s,,。;]+(?:分组|汇总|统计)", text))
1102
+ if match is None:
1103
+ if chinese_group_intent:
1104
+ raise SchemaValidationError("schema group column is not explicitly resolvable")
1105
+ return []
1106
+ raw_column, tail = match.groups()
1107
+ if tail.strip(" \t\r\n,.;,。;"):
1108
+ raise SchemaValidationError("multiple schema group columns are unsupported")
1109
+ parts = raw_column.split(".")
1110
+ if len(parts) > 1:
1111
+ candidates = schema.table_candidates(".".join(parts[:-1]))
1112
+ if len(candidates) != 1 or candidates[0].key != table.key:
1113
+ raise SchemaValidationError("group column table is not the selected schema table")
1114
+ canonical = schema.canonical_column(table, parts[-1])
1115
+ if canonical is None:
1116
+ if not table.has_column_metadata and match_kind == "group":
1117
+ return [parts[-1]]
1118
+ raise SchemaValidationError("group column is outside the schema")
1119
+ return [canonical]
1120
+
1121
+ @staticmethod
1122
+ def _schema_has_explicit_ordering(text: str) -> bool:
1123
+ lowered = text.lower()
1124
+ phrases = (
1125
+ "order by", "ordered by", "sort by", "sorted by",
1126
+ "ascending", "descending", "asc", "desc", "sort", "sorted",
1127
+ "top", "highest", "lowest", "max", "min",
1128
+ "升序", "降序", "排序", "最大", "最小", "最多", "最少",
1129
+ )
1130
+ for phrase in phrases:
1131
+ if phrase.isascii():
1132
+ if re.search(r"(?<![\w$])" + re.escape(phrase) + r"(?![\w$])", lowered):
1133
+ return True
1134
+ elif phrase in text:
1135
+ return True
1136
+ return False
1137
+
712
1138
  def _schema_resolve_conditions(
713
1139
  self,
714
1140
  text: str,
@@ -736,6 +1162,325 @@ class PipelineNL2SQLGenerator:
736
1162
  resolved.append(rewritten)
737
1163
  return resolved
738
1164
 
1165
+ @staticmethod
1166
+ def _schema_mask_span(
1167
+ text: str, span: tuple[int, int]
1168
+ ) -> str:
1169
+ start, end = span
1170
+ return text[:start] + " " * (end - start) + text[end:]
1171
+
1172
+ def _schema_resolve_whose(
1173
+ self,
1174
+ text: str,
1175
+ table: SchemaTableContext,
1176
+ schema: SchemaContext,
1177
+ ) -> tuple[str, tuple[int, int]] | None:
1178
+ """Resolve one schema-aware ``whose`` predicate in linear time."""
1179
+ executable = mask_non_executable(text)
1180
+ match = _SCHEMA_WHOSE_PREFIX.search(executable)
1181
+ if match is None:
1182
+ return None
1183
+ whose_matches = list(_SCHEMA_WHOSE_PREFIX.finditer(executable))
1184
+ if len(whose_matches) != 1:
1185
+ raise SchemaValidationError("multiple whose predicates are unsupported")
1186
+ original_match = _SCHEMA_WHOSE_PREFIX.match(text, match.start())
1187
+ if original_match is None:
1188
+ raise SchemaValidationError("whose predicate is not executable")
1189
+ match = original_match
1190
+
1191
+ raw_column, raw_operator = match.groups()
1192
+ parts = raw_column.split(".")
1193
+ column_name = parts[-1]
1194
+ if len(parts) > 1:
1195
+ candidates = schema.table_candidates(".".join(parts[:-1]))
1196
+ if len(candidates) != 1 or candidates[0].key != table.key:
1197
+ raise SchemaValidationError("whose column table is not the selected table")
1198
+ canonical = schema.canonical_column(table, column_name)
1199
+ if canonical is None:
1200
+ if table.has_column_metadata:
1201
+ raise SchemaValidationError("whose column is outside the schema")
1202
+ canonical = column_name
1203
+
1204
+ position = match.end()
1205
+ if position >= len(text):
1206
+ raise SchemaValidationError("whose predicate value is missing")
1207
+ quote = text[position] if text[position] in "'\"" else None
1208
+ value_end = position
1209
+ if quote is not None:
1210
+ position += 1
1211
+ value_chars: list[str] = []
1212
+ closed = False
1213
+ while position < len(text):
1214
+ character = text[position]
1215
+ if character == "\\" and position + 1 < len(text):
1216
+ value_chars.extend((character, text[position + 1]))
1217
+ position += 2
1218
+ continue
1219
+ if character == quote:
1220
+ if position + 1 < len(text) and text[position + 1] == quote:
1221
+ value_chars.append(quote)
1222
+ position += 2
1223
+ continue
1224
+ closed = True
1225
+ position += 1
1226
+ break
1227
+ value_chars.append(character)
1228
+ position += 1
1229
+ if not closed:
1230
+ raise SchemaValidationError("whose quoted value is not closed")
1231
+ value = "".join(value_chars)
1232
+ literal = "'" + value.replace("'", "''") + "'"
1233
+ value_end = position
1234
+ else:
1235
+ numeric = re.match(
1236
+ r"-?(?:\d+(?:\.\d*)?|\.\d+)(?![\w$])",
1237
+ text[position:],
1238
+ )
1239
+ if numeric is not None:
1240
+ literal = numeric.group(0)
1241
+ value_end = position + numeric.end()
1242
+ else:
1243
+ null_match = re.match(r"null(?![\w$])", text[position:], re.IGNORECASE)
1244
+ if null_match is not None:
1245
+ literal = "NULL"
1246
+ value_end = position + null_match.end()
1247
+ else:
1248
+ cjk_token = re.match(r"[\u4e00-\u9fff]+", text[position:])
1249
+ if cjk_token is not None:
1250
+ value = cjk_token.group(0)
1251
+ if any(connector in value for connector in _SCHEMA_WHOSE_CJK_CONNECTORS):
1252
+ raise SchemaValidationError("whose CJK value contains a connector")
1253
+ literal = "'" + value.replace("'", "''") + "'"
1254
+ value_end = position + cjk_token.end()
1255
+ else:
1256
+ token = re.match(r"[A-Za-z_][A-Za-z0-9_$]*(?![\w$])", text[position:])
1257
+ if token is None:
1258
+ raise SchemaValidationError("whose value is not a supported literal")
1259
+ value = token.group(0)
1260
+ if value.casefold() in _SCHEMA_WHOSE_REJECTED_VALUES:
1261
+ raise SchemaValidationError("whose value is a reserved word")
1262
+ after_token = text[position + token.end():].lstrip()
1263
+ if after_token.startswith("("):
1264
+ raise SchemaValidationError("whose function values are unsupported")
1265
+ literal = "'" + value.replace("'", "''") + "'"
1266
+ value_end = position + token.end()
1267
+
1268
+ operator = raw_operator.casefold()
1269
+ if literal == "NULL":
1270
+ if operator in {"is", "is not", "isn't"}:
1271
+ predicate = f"{canonical} IS {'NOT ' if operator != 'is' else ''}NULL"
1272
+ else:
1273
+ raise SchemaValidationError("comparison with NULL is unsupported")
1274
+ else:
1275
+ normalized = {
1276
+ "is": "=", "equals": "=", "is not": "!=", "isn't": "!=",
1277
+ "<>": "!=",
1278
+ }.get(operator, raw_operator)
1279
+ predicate = f"{canonical} {normalized} {literal}"
1280
+
1281
+ tail = executable[value_end:]
1282
+ if re.search(r"\bor\b|\bwhose\b|[()]", tail, re.IGNORECASE):
1283
+ raise SchemaValidationError("complex whose predicates are unsupported")
1284
+ stripped_tail = tail.strip()
1285
+ if stripped_tail and not re.match(r"^and\b", stripped_tail, re.IGNORECASE):
1286
+ raise SchemaValidationError("unexpected text after whose predicate")
1287
+ span = (match.start(), value_end)
1288
+ return predicate, span
1289
+
1290
+ def _schema_resolve_joined_on(
1291
+ self,
1292
+ text: str,
1293
+ table_hint: Optional[str],
1294
+ schema: SchemaContext,
1295
+ ) -> tuple[SchemaTableContext, list[dict], tuple[int, int]] | None:
1296
+ """Resolve one schema-aware qualified equality after ``joined on``."""
1297
+ executable = mask_non_executable(text)
1298
+ prefixes = list(_SCHEMA_JOINED_ON_PREFIX.finditer(executable))
1299
+ if not prefixes:
1300
+ return None
1301
+ if re.search(r"\bjoined\s+with\b", executable, re.IGNORECASE):
1302
+ raise SchemaValidationError("mixed joined-on and joined-with clauses are unsupported")
1303
+ if len(prefixes) != 1:
1304
+ raise SchemaValidationError("multiple joined-on clauses are unsupported")
1305
+ if re.search(r"\b(?:per|each)\b|每个|各个|每位", executable, re.IGNORECASE):
1306
+ raise SchemaValidationError("joined-on with group labels is unsupported")
1307
+ prefix = prefixes[0]
1308
+ original_prefix = _SCHEMA_JOINED_ON_PREFIX.match(text, prefix.start())
1309
+ if original_prefix is None:
1310
+ raise SchemaValidationError("joined-on clause is not executable")
1311
+ prefix = original_prefix
1312
+ kind = (prefix.group(1) or "inner").upper()
1313
+ before_mentions = self._schema_table_mentions(text[:prefix.start()], schema)
1314
+ if table_hint:
1315
+ subject_candidates = schema.table_candidates(table_hint)
1316
+ if len(subject_candidates) != 1:
1317
+ raise SchemaValidationError("joined-on subject hint is ambiguous")
1318
+ subject = subject_candidates[0]
1319
+ elif before_mentions:
1320
+ subject = before_mentions[0]
1321
+ else:
1322
+ raise SchemaValidationError("joined-on subject is missing")
1323
+
1324
+ remainder = executable[prefix.end():]
1325
+ left_match = _SCHEMA_JOINED_ON_OPERAND.match(remainder.lstrip())
1326
+ if left_match is None:
1327
+ raise SchemaValidationError("joined-on left endpoint is invalid")
1328
+ left_start = prefix.end() + (len(remainder) - len(remainder.lstrip()))
1329
+ left_raw = left_match.group(0)
1330
+ after_left = remainder.lstrip()[left_match.end():]
1331
+ equals = re.match(r"\s*=\s*", after_left)
1332
+ if equals is None:
1333
+ raise SchemaValidationError("joined-on requires one equality")
1334
+ right_offset = left_start + left_match.end() + equals.end()
1335
+ right_match = _SCHEMA_JOINED_ON_OPERAND.match(executable[right_offset:])
1336
+ if right_match is None:
1337
+ raise SchemaValidationError("joined-on right endpoint is invalid")
1338
+ right_raw = right_match.group(0)
1339
+ value_end = right_offset + right_match.end()
1340
+
1341
+ tail = executable[value_end:]
1342
+ stripped_tail = tail.strip()
1343
+ if stripped_tail and not re.match(r"^(?:where|whose)\b", stripped_tail, re.IGNORECASE):
1344
+ raise SchemaValidationError("unexpected text after joined-on equality")
1345
+
1346
+ def resolve_endpoint(raw: str) -> tuple[SchemaTableContext, str]:
1347
+ parts = raw.split(".")
1348
+ table_name = ".".join(parts[:-1])
1349
+ candidates = schema.table_candidates(table_name)
1350
+ if len(candidates) != 1:
1351
+ raise SchemaValidationError("joined-on table is not unique")
1352
+ table = candidates[0]
1353
+ column = schema.canonical_column(table, parts[-1])
1354
+ if column is None:
1355
+ raise SchemaValidationError("joined-on column is outside the schema")
1356
+ return table, column
1357
+
1358
+ left_table, left_column = resolve_endpoint(left_raw)
1359
+ right_table, right_column = resolve_endpoint(right_raw)
1360
+ if left_table.key == right_table.key:
1361
+ raise SchemaValidationError("self joined-on is unsupported")
1362
+ endpoint_tables = {left_table.key, right_table.key}
1363
+ if subject.key not in endpoint_tables:
1364
+ raise SchemaValidationError("joined-on subject is not an endpoint")
1365
+ target_keys = endpoint_tables - {subject.key}
1366
+ if len(target_keys) != 1:
1367
+ raise SchemaValidationError("joined-on target is ambiguous")
1368
+ target = right_table if right_table.key in target_keys else left_table
1369
+ mentions = self._schema_table_mentions(text, schema)
1370
+ if {table.key for table in mentions} - endpoint_tables:
1371
+ raise SchemaValidationError("joined-on contains an extra table")
1372
+
1373
+ raw_relationships = [
1374
+ relationship for relationship in schema.relationships
1375
+ if {
1376
+ relationship.from_table.casefold(), relationship.to_table.casefold()
1377
+ } == endpoint_tables
1378
+ ]
1379
+ if raw_relationships:
1380
+ def matches_relationship(relationship) -> bool:
1381
+ declared = {
1382
+ (relationship.from_table.casefold(), relationship.from_column.casefold()),
1383
+ (relationship.to_table.casefold(), relationship.to_column.casefold()),
1384
+ }
1385
+ supplied = {
1386
+ (left_table.key, left_column.casefold()),
1387
+ (right_table.key, right_column.casefold()),
1388
+ }
1389
+ return declared == supplied
1390
+
1391
+ if sum(matches_relationship(item) for item in raw_relationships) != 1:
1392
+ raise SchemaValidationError("joined-on does not match a unique relationship")
1393
+
1394
+ condition = (
1395
+ f"{left_table.name}.{left_column} = "
1396
+ f"{right_table.name}.{right_column}"
1397
+ )
1398
+ span = (prefix.start(), value_end)
1399
+ return subject, [{
1400
+ "type": kind,
1401
+ "table": target.name,
1402
+ "subject": subject.name,
1403
+ "condition": condition,
1404
+ "relational": False,
1405
+ "join_spec": JoinSpec(kind=kind, matched_text="joined on"),
1406
+ }], span
1407
+
1408
+ def _schema_resolve_joined_with(
1409
+ self,
1410
+ text: str,
1411
+ table_hint: Optional[str],
1412
+ schema: SchemaContext,
1413
+ ) -> tuple[SchemaTableContext, list[dict], tuple[int, int]] | None:
1414
+ """Resolve one relationship-backed physical ``joined with`` clause."""
1415
+ executable = mask_non_executable(text)
1416
+ matches = list(_SCHEMA_JOINED_WITH_PREFIX.finditer(executable))
1417
+ if not matches:
1418
+ return None
1419
+ if len(matches) != 1 or re.search(r"\bjoined\s+on\b", executable, re.IGNORECASE):
1420
+ raise SchemaValidationError("multiple or mixed joined clauses are unsupported")
1421
+ match = matches[0]
1422
+ original_match = _SCHEMA_JOINED_WITH_PREFIX.match(text, match.start())
1423
+ if original_match is None:
1424
+ raise SchemaValidationError("joined-with clause is not executable")
1425
+ match = original_match
1426
+ if re.search(
1427
+ r"\b(?:per|each|group\s+by|grouped\s+by)\b|每个|各个|每位",
1428
+ executable,
1429
+ re.IGNORECASE,
1430
+ ):
1431
+ raise SchemaValidationError("joined-with grouping is unsupported")
1432
+
1433
+ kind = (match.group(1) or "inner").upper()
1434
+ target_name = match.group(2)
1435
+ target_candidates = schema.table_candidates(target_name)
1436
+ if len(target_candidates) != 1:
1437
+ raise SchemaValidationError("joined-with target is not unique")
1438
+ target = target_candidates[0]
1439
+ before_mentions = self._schema_table_mentions(text[:match.start()], schema)
1440
+ if table_hint:
1441
+ subject_candidates = schema.table_candidates(table_hint)
1442
+ if len(subject_candidates) != 1:
1443
+ raise SchemaValidationError("joined-with subject hint is ambiguous")
1444
+ subject = subject_candidates[0]
1445
+ elif before_mentions:
1446
+ subject = before_mentions[0]
1447
+ else:
1448
+ raise SchemaValidationError("joined-with subject is missing")
1449
+ if self._schema_text_match(text[:match.start()], target_name):
1450
+ raise SchemaValidationError("joined-with target is repeated before the clause")
1451
+ before_keys = {table.key for table in before_mentions}
1452
+ if before_keys - {subject.key, target.key}:
1453
+ raise SchemaValidationError("joined-with contains an extra table")
1454
+ if subject.key == target.key:
1455
+ raise SchemaValidationError("self joined-with is unsupported")
1456
+
1457
+ tail = executable[match.end():]
1458
+ stripped_tail = tail.strip()
1459
+ if stripped_tail and not re.match(r"^(?:where|whose)\b", stripped_tail, re.IGNORECASE):
1460
+ raise SchemaValidationError("unexpected text after joined-with target")
1461
+ relationships = [
1462
+ relationship for relationship in schema.relationships
1463
+ if {
1464
+ relationship.from_table.casefold(), relationship.to_table.casefold()
1465
+ } == {subject.key, target.key}
1466
+ ]
1467
+ if len(relationships) != 1:
1468
+ raise SchemaValidationError("joined-with relationship is not unique")
1469
+ relationship = relationships[0]
1470
+ condition = (
1471
+ f"{relationship.from_table}.{relationship.from_column} = "
1472
+ f"{relationship.to_table}.{relationship.to_column}"
1473
+ )
1474
+ span = (match.start(), match.end())
1475
+ return subject, [{
1476
+ "type": kind,
1477
+ "table": target.name,
1478
+ "subject": subject.name,
1479
+ "condition": condition,
1480
+ "relational": False,
1481
+ "join_spec": JoinSpec(kind=kind, matched_text="joined with"),
1482
+ }], span
1483
+
739
1484
  def _schema_resolve_joins(
740
1485
  self,
741
1486
  text: str,
@@ -867,35 +1612,105 @@ class PipelineNL2SQLGenerator:
867
1612
  ) -> NL2SQLResult:
868
1613
  """Generate through the enhanced path with strict schema resolution."""
869
1614
  try:
870
- unknown_tables = self._schema_unknown_table_patterns(text, schema)
1615
+ executable_text = mask_non_executable(text)
1616
+ joined_on_resolution = self._schema_resolve_joined_on(
1617
+ text, table_hint, schema
1618
+ )
1619
+ joined_with_resolution = None
1620
+ if joined_on_resolution is None:
1621
+ joined_with_resolution = self._schema_resolve_joined_with(
1622
+ text, table_hint, schema
1623
+ )
1624
+ label_resolution = None
1625
+ if joined_on_resolution is None and joined_with_resolution is None:
1626
+ label_resolution = self._schema_resolve_english_group_label(executable_text, schema)
1627
+ if label_resolution is None:
1628
+ label_resolution = self._schema_resolve_chinese_group_label(executable_text, schema)
1629
+ ignored_label_spans = (label_resolution[2],) if label_resolution else ()
1630
+ unknown_tables = self._schema_unknown_table_patterns(
1631
+ text, schema, ignored_spans=ignored_label_spans
1632
+ )
871
1633
  if unknown_tables:
872
1634
  return self._schema_failure(
873
1635
  text,
874
1636
  dialect,
875
1637
  "The request references tables outside the provided schema.",
876
1638
  )
877
- table_context, joins = self._schema_resolve_joins(text, table_hint, schema)
1639
+ joined_on_span = None
1640
+ joined_with_span = None
1641
+ if joined_on_resolution is not None:
1642
+ table_context, joins, joined_on_span = joined_on_resolution
1643
+ metric_text = self._schema_mask_span(text_lower, joined_on_span)
1644
+ elif joined_with_resolution is not None:
1645
+ table_context, joins, joined_with_span = joined_with_resolution
1646
+ metric_text = self._schema_mask_span(text_lower, joined_with_span)
1647
+ elif label_resolution is not None:
1648
+ table_context = label_resolution[0]
1649
+ label_group_column = label_resolution[1]
1650
+ joins = []
1651
+ start, end = label_resolution[2]
1652
+ metric_text = text_lower[:start] + " " * (end - start) + text_lower[end:]
1653
+ else:
1654
+ table_context, joins = self._schema_resolve_joins(text, table_hint, schema)
1655
+ metric_text = text_lower
1656
+ whose_resolution = self._schema_resolve_whose(text, table_context, schema)
1657
+ whose_condition = None
1658
+ condition_text = text_lower
1659
+ condition_original = text
1660
+ if joined_on_span is not None:
1661
+ condition_text = self._schema_mask_span(condition_text, joined_on_span)
1662
+ condition_original = self._schema_mask_span(condition_original, joined_on_span)
1663
+ elif joined_with_span is not None:
1664
+ condition_text = self._schema_mask_span(condition_text, joined_with_span)
1665
+ condition_original = self._schema_mask_span(condition_original, joined_with_span)
1666
+ if whose_resolution is not None:
1667
+ whose_condition, whose_span = whose_resolution
1668
+ metric_text = self._schema_mask_span(metric_text, whose_span)
1669
+ condition_text = self._schema_mask_span(condition_text, whose_span)
1670
+ condition_original = self._schema_mask_span(condition_original, whose_span)
1671
+ count_metric = self._schema_resolve_count_metric(
1672
+ text_lower, table_context, schema
1673
+ )
878
1674
  columns = self._schema_resolve_columns(
879
- text_lower, table_context, column_hints, schema
1675
+ metric_text,
1676
+ table_context,
1677
+ column_hints,
1678
+ schema,
1679
+ suppress_quantity_token=count_metric is not None,
880
1680
  )
881
1681
  conditions = self._schema_resolve_conditions(
882
- text_lower,
883
- self._extract_conditions_enhanced(text_lower, text),
1682
+ condition_text,
1683
+ self._extract_conditions_enhanced(condition_text, condition_original),
884
1684
  table_context,
885
1685
  schema,
886
1686
  )
1687
+ if whose_condition is not None:
1688
+ conditions = [*conditions, whose_condition]
887
1689
  parsed = self._parse_text(text_lower, text)
888
1690
  parsed.update(analysis)
889
1691
  operation = self._detect_operation(text_lower)
890
- aggregations = self._extract_aggregations(text_lower)
891
- group_by = self._extract_group_by(text_lower)
892
- ordering = self._extract_ordering(text_lower)
1692
+ aggregations = self._schema_extract_aggregations(
1693
+ metric_text, table_context, schema, count_metric
1694
+ )
1695
+ group_by = (
1696
+ [label_group_column]
1697
+ if label_resolution is not None
1698
+ else self._schema_resolve_group_by(
1699
+ text_lower, table_context, schema, aggregations
1700
+ )
1701
+ )
1702
+ ordering = (
1703
+ self._schema_resolve_ordering(text_lower, table_context, schema)
1704
+ if self._schema_has_explicit_ordering(text_lower)
1705
+ else None
1706
+ )
893
1707
  limit = self._extract_limit(text_lower)
894
1708
  distinct = self._check_distinct(text_lower)
1709
+ render_columns = ["*"] if aggregations and not group_by else columns
895
1710
  sql, explanation, confidence, evidence = self._build_sql_enhanced(
896
1711
  operation,
897
1712
  table_context.name,
898
- columns,
1713
+ render_columns,
899
1714
  conditions,
900
1715
  aggregations,
901
1716
  group_by,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sql-dialect-master"
3
- version = "1.4.1"
3
+ version = "1.6.0"
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.4.1
3
+ Version: 1.6.0
4
4
  Summary: SQL Dialect Master - Multi-database SQL conversion engine
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -38,7 +38,7 @@ Dynamic: license-file
38
38
 
39
39
  Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
40
40
 
41
- Current release: 1.4.1
41
+ Current release: 1.6.0
42
42
 
43
43
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
44
44
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)