sql-dialect-master 1.3.0__tar.gz → 1.4.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.
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/PKG-INFO +15 -2
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/README.md +14 -1
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/api/main.py +58 -20
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/metadata.py +2 -2
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_llm.py +11 -115
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_pipeline/orchestrator.py +376 -0
- sql_dialect_master-1.4.0/backend/core/nl2sql_schema.py +403 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/pyproject.toml +1 -1
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/sql_dialect_master.egg-info/PKG-INFO +15 -2
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/sql_dialect_master.egg-info/SOURCES.txt +1 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/LICENSE +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/__init__.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/api/__init__.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/api/auth.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/api/client_identity.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/api/metrics.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/api/middleware.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/api/rate_limit_store.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/api/readiness.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/api/workers.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/__init__.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/cache.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/column_hint_validation.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/config.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/constants.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/exceptions.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/function_call_scanner.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/function_semantics.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/functions_db.json +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/functions_lookup.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_components/__init__.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_components/aggregations.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_components/boolean_conditions.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_components/core_extract.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_components/evidence.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_components/mappings.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_components/relations.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_components/templates.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_components/tokenizer.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_legacy.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_pipeline/__init__.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_pipeline/aggregations.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_pipeline/clause_extract.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_pipeline/dialect.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_pipeline/intent.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_pipeline/ir.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_pipeline/models.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_pipeline/predicates.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_pipeline/renderer.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_pipeline/resolution.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_pipeline/suggestions.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_pipeline/templates.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/nl2sql_pipeline/validation.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/p1_sql_scanner.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/p2_data_validation.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/parser.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/post_processor.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/rules.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/semantic_diff.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/sql_statements.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/transpiler.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/type_mapping.json +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/core/type_mapping.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/utils/__init__.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/backend/utils/validators.py +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/setup.cfg +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/sql_dialect_master.egg-info/dependency_links.txt +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.0}/sql_dialect_master.egg-info/requires.txt +0 -0
- {sql_dialect_master-1.3.0 → sql_dialect_master-1.4.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.
|
|
3
|
+
Version: 1.4.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.
|
|
41
|
+
Current release: 1.4.0
|
|
42
42
|
|
|
43
43
|
[](https://www.python.org/downloads/)
|
|
44
44
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -177,6 +177,19 @@ print(result.sql)
|
|
|
177
177
|
# Output: SELECT * FROM orders WHERE created_at >= DATE_SUB(CURRENT_DATE, 7)
|
|
178
178
|
```
|
|
179
179
|
|
|
180
|
+
An optional request `schema` can ground both deterministic and LLM
|
|
181
|
+
generation. It contains `tables` with optional `columns`, plus explicit
|
|
182
|
+
`relationships`. Table and column references that the deterministic parser maps
|
|
183
|
+
to the schema are matched case-insensitively; ambiguous mapped references and
|
|
184
|
+
multi-table requests without a declared relationship fail closed instead of
|
|
185
|
+
falling back to hardcoded table or column names. Empty column metadata means
|
|
186
|
+
that the table's columns are unrestricted. Natural-language column names
|
|
187
|
+
outside the deterministic mapping may be ignored; use `column_hints` or the
|
|
188
|
+
LLM path for those.
|
|
189
|
+
The response field `schema_used` is true when a successful deterministic or LLM
|
|
190
|
+
generation was validated against the supplied schema. With no schema, the
|
|
191
|
+
existing deterministic behavior is unchanged.
|
|
192
|
+
|
|
180
193
|
#### Optional LLM (OpenRouter)
|
|
181
194
|
|
|
182
195
|
`POST /api/nl2sql` can optionally call an OpenRouter-compatible chat-completions
|
|
@@ -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.
|
|
5
|
+
Current release: 1.4.0
|
|
6
6
|
|
|
7
7
|
[](https://www.python.org/downloads/)
|
|
8
8
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -141,6 +141,19 @@ print(result.sql)
|
|
|
141
141
|
# Output: SELECT * FROM orders WHERE created_at >= DATE_SUB(CURRENT_DATE, 7)
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
+
An optional request `schema` can ground both deterministic and LLM
|
|
145
|
+
generation. It contains `tables` with optional `columns`, plus explicit
|
|
146
|
+
`relationships`. Table and column references that the deterministic parser maps
|
|
147
|
+
to the schema are matched case-insensitively; ambiguous mapped references and
|
|
148
|
+
multi-table requests without a declared relationship fail closed instead of
|
|
149
|
+
falling back to hardcoded table or column names. Empty column metadata means
|
|
150
|
+
that the table's columns are unrestricted. Natural-language column names
|
|
151
|
+
outside the deterministic mapping may be ignored; use `column_hints` or the
|
|
152
|
+
LLM path for those.
|
|
153
|
+
The response field `schema_used` is true when a successful deterministic or LLM
|
|
154
|
+
generation was validated against the supplied schema. With no schema, the
|
|
155
|
+
existing deterministic behavior is unchanged.
|
|
156
|
+
|
|
144
157
|
#### Optional LLM (OpenRouter)
|
|
145
158
|
|
|
146
159
|
`POST /api/nl2sql` can optionally call an OpenRouter-compatible chat-completions
|
|
@@ -53,6 +53,7 @@ from backend.core.functions_lookup import FunctionEncyclopedia
|
|
|
53
53
|
from backend.core.nl2sql import NL2SQLGenerator, NL2SQLResult
|
|
54
54
|
from backend.core.nl2sql_components.evidence import EvidenceItem, GenerationEvidence
|
|
55
55
|
from backend.core.nl2sql_llm import LLMError, LLMN2SQLClient, validate_llm_sql
|
|
56
|
+
from backend.core.nl2sql_schema import SchemaContext
|
|
56
57
|
from backend.core.nl2sql_pipeline.orchestrator import SAFE_TABLE_HINT
|
|
57
58
|
from backend.core.parser import SUPPORTED_DIALECTS, SQLParser
|
|
58
59
|
from backend.core.semantic_diff import diff_sql_ast
|
|
@@ -468,12 +469,19 @@ class SchemaRelationship(BaseModel):
|
|
|
468
469
|
|
|
469
470
|
class SchemaDefinition(BaseModel):
|
|
470
471
|
tables: List[SchemaTable] = Field(default_factory=list, max_length=50)
|
|
471
|
-
relationships: List[SchemaRelationship] = Field(default_factory=list)
|
|
472
|
+
relationships: List[SchemaRelationship] = Field(default_factory=list, max_length=200)
|
|
472
473
|
|
|
473
474
|
@model_validator(mode="after")
|
|
474
475
|
def validate_total_columns(self):
|
|
475
476
|
if sum(len(table.columns) for table in self.tables) > 1000:
|
|
476
477
|
raise ValueError("Schema must contain at most 1000 columns in total")
|
|
478
|
+
table_names = {table.name.casefold() for table in self.tables}
|
|
479
|
+
for relationship in self.relationships:
|
|
480
|
+
if (
|
|
481
|
+
relationship.from_table.casefold() not in table_names
|
|
482
|
+
or relationship.to_table.casefold() not in table_names
|
|
483
|
+
):
|
|
484
|
+
raise ValueError("Schema relationship references an unknown table")
|
|
477
485
|
return self
|
|
478
486
|
|
|
479
487
|
|
|
@@ -979,6 +987,14 @@ async def generate_sql(request: NL2SQLRequest):
|
|
|
979
987
|
"dotted identifier"
|
|
980
988
|
),
|
|
981
989
|
)
|
|
990
|
+
schema_payload = (
|
|
991
|
+
request.schema_definition.model_dump(exclude_none=True)
|
|
992
|
+
if request.schema_definition is not None else None
|
|
993
|
+
)
|
|
994
|
+
schema_context = (
|
|
995
|
+
SchemaContext.from_mapping(schema_payload)
|
|
996
|
+
if schema_payload is not None else None
|
|
997
|
+
)
|
|
982
998
|
use_llm = (
|
|
983
999
|
settings.nl2sql_llm_enabled
|
|
984
1000
|
and bool(request.text.strip())
|
|
@@ -987,18 +1003,25 @@ async def generate_sql(request: NL2SQLRequest):
|
|
|
987
1003
|
and nl2sql_generator._detect_operation(request.text.lower()) not in {"INSERT", "UPDATE", "DELETE"}
|
|
988
1004
|
)
|
|
989
1005
|
if not use_llm:
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1006
|
+
if schema_context is None:
|
|
1007
|
+
result = await _run_cpu_bound(
|
|
1008
|
+
nl2sql_generator.generate,
|
|
1009
|
+
request.text,
|
|
1010
|
+
dialect,
|
|
1011
|
+
request.table_hint,
|
|
1012
|
+
request.column_hints,
|
|
1013
|
+
)
|
|
1014
|
+
else:
|
|
1015
|
+
result = await _run_cpu_bound(
|
|
1016
|
+
nl2sql_generator.generate,
|
|
1017
|
+
request.text,
|
|
1018
|
+
dialect,
|
|
1019
|
+
request.table_hint,
|
|
1020
|
+
request.column_hints,
|
|
1021
|
+
schema_context,
|
|
1022
|
+
)
|
|
997
1023
|
else:
|
|
998
|
-
schema =
|
|
999
|
-
request.schema_definition.model_dump(exclude_none=True)
|
|
1000
|
-
if request.schema_definition is not None else None
|
|
1001
|
-
)
|
|
1024
|
+
schema = schema_payload
|
|
1002
1025
|
started = time.perf_counter()
|
|
1003
1026
|
try:
|
|
1004
1027
|
# Keep the legacy no-schema client call compatible with direct
|
|
@@ -1013,17 +1036,32 @@ async def generate_sql(request: NL2SQLRequest):
|
|
|
1013
1036
|
record_nl2sql_llm_request(exc.reason, time.perf_counter() - started)
|
|
1014
1037
|
if settings.nl2sql_llm_fallback:
|
|
1015
1038
|
record_nl2sql_llm_fallback()
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1039
|
+
if schema_context is None:
|
|
1040
|
+
deterministic_result = await _run_cpu_bound(
|
|
1041
|
+
nl2sql_generator.generate,
|
|
1042
|
+
request.text,
|
|
1043
|
+
dialect,
|
|
1044
|
+
request.table_hint,
|
|
1045
|
+
request.column_hints,
|
|
1046
|
+
)
|
|
1047
|
+
else:
|
|
1048
|
+
deterministic_result = await _run_cpu_bound(
|
|
1049
|
+
nl2sql_generator.generate,
|
|
1050
|
+
request.text,
|
|
1051
|
+
dialect,
|
|
1052
|
+
request.table_hint,
|
|
1053
|
+
request.column_hints,
|
|
1054
|
+
schema_context,
|
|
1055
|
+
)
|
|
1056
|
+
result = copy.copy(deterministic_result)
|
|
1023
1057
|
result.provider = "deterministic"
|
|
1024
1058
|
result.model = None
|
|
1025
1059
|
result.fallback_reason = exc.reason
|
|
1026
|
-
result.schema_used =
|
|
1060
|
+
result.schema_used = bool(
|
|
1061
|
+
schema_context is not None
|
|
1062
|
+
and deterministic_result.success
|
|
1063
|
+
and deterministic_result.schema_used
|
|
1064
|
+
)
|
|
1027
1065
|
else:
|
|
1028
1066
|
result = NL2SQLResult(
|
|
1029
1067
|
input_text=request.text,
|
|
@@ -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.
|
|
21
|
-
API_VERSION = "1.
|
|
20
|
+
PROJECT_VERSION = "1.4.0"
|
|
21
|
+
API_VERSION = "1.4.0"
|
|
22
22
|
# Frontend v2 architecture — independent of the product release version.
|
|
23
23
|
FRONTEND_ARCHITECTURE_VERSION = 2
|
|
24
24
|
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
"""Opt-in OpenRouter-compatible generation with strict SQL output validation."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from collections.abc import Iterable, Sequence
|
|
5
4
|
from contextvars import ContextVar
|
|
6
5
|
from dataclasses import dataclass
|
|
7
6
|
import json
|
|
8
7
|
import logging
|
|
9
8
|
import re
|
|
10
|
-
from typing import cast
|
|
11
9
|
|
|
12
10
|
import httpx
|
|
13
11
|
from sqlglot import exp
|
|
14
|
-
from sqlglot.optimizer.scope import
|
|
12
|
+
from sqlglot.optimizer.scope import traverse_scope
|
|
15
13
|
|
|
16
14
|
from .config import AppSettings, settings
|
|
15
|
+
from .nl2sql_schema import SchemaContext, validate_schema_references
|
|
17
16
|
from .sql_statements import parse_single_sql
|
|
18
17
|
|
|
19
18
|
|
|
@@ -24,7 +23,6 @@ _ERROR_REASONS = frozenset({
|
|
|
24
23
|
_VALIDATING_LLM_SQL = ContextVar("validating_llm_sql", default=False)
|
|
25
24
|
_SQLGLOT_LOGGER = logging.getLogger("sqlglot")
|
|
26
25
|
_SQL_FENCE = re.compile(r"```(?:sql)?[ \t]*\r?\n(.*?)\r?\n?```", re.IGNORECASE | re.DOTALL)
|
|
27
|
-
_SCHEMA_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_$]*")
|
|
28
26
|
|
|
29
27
|
|
|
30
28
|
class LLMError(Exception):
|
|
@@ -48,118 +46,16 @@ class _LLMValidationLogFilter(logging.Filter):
|
|
|
48
46
|
return not _VALIDATING_LLM_SQL.get()
|
|
49
47
|
|
|
50
48
|
|
|
51
|
-
def _schema_reference_name(parts: Sequence[exp.Expr]) -> str:
|
|
52
|
-
# A quoted literal name such as "public.users" must not be confused
|
|
53
|
-
# with two separate schema/table identifiers, public.users.
|
|
54
|
-
if not parts or any(
|
|
55
|
-
not isinstance(part, exp.Identifier) or not _SCHEMA_IDENTIFIER.fullmatch(part.name)
|
|
56
|
-
for part in parts
|
|
57
|
-
):
|
|
58
|
-
raise LLMError("invalid_sql")
|
|
59
|
-
return ".".join(part.name for part in parts).casefold()
|
|
60
|
-
|
|
61
|
-
|
|
62
49
|
def _validate_schema_references(statement: exp.Expression, schema: dict) -> None:
|
|
63
|
-
"""
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
sources: dict[str, exp.Table | Scope] = {}
|
|
73
|
-
for name, (_, source) in scope.selected_sources.items():
|
|
74
|
-
alias = name.casefold()
|
|
75
|
-
if alias in sources:
|
|
76
|
-
raise LLMError("invalid_sql")
|
|
77
|
-
sources[alias] = source
|
|
78
|
-
if isinstance(source, exp.Table):
|
|
79
|
-
table_name = _schema_reference_name(source.parts)
|
|
80
|
-
if table_name not in table_columns:
|
|
81
|
-
raise LLMError("invalid_sql")
|
|
82
|
-
# A fully qualified column must match the complete source name,
|
|
83
|
-
# and cannot resolve an unrelated schema through a shared basename.
|
|
84
|
-
if not source.alias:
|
|
85
|
-
sources[table_name] = source
|
|
86
|
-
# SEMI/ANTI join sources are omitted by selected_sources, but still
|
|
87
|
-
# reference physical tables and must obey the same schema constraints.
|
|
88
|
-
for table in scope.tables:
|
|
89
|
-
table_source = scope.sources.get(table.alias_or_name)
|
|
90
|
-
if isinstance(table_source, exp.Table):
|
|
91
|
-
table_name = _schema_reference_name(table_source.parts)
|
|
92
|
-
if table_name not in table_columns:
|
|
93
|
-
raise LLMError("invalid_sql")
|
|
94
|
-
sources.setdefault(table.alias_or_name.casefold(), table_source)
|
|
95
|
-
sources_by_scope[id(scope)] = sources
|
|
96
|
-
|
|
97
|
-
def resolve_source(scope: Scope, qualifier: str) -> exp.Table | Scope:
|
|
98
|
-
current: Scope | None = scope
|
|
99
|
-
while current is not None:
|
|
100
|
-
source = sources_by_scope.get(id(current), {}).get(qualifier)
|
|
101
|
-
if source is not None:
|
|
102
|
-
return source
|
|
103
|
-
if not current.can_be_correlated:
|
|
104
|
-
break
|
|
105
|
-
current = current.parent
|
|
106
|
-
raise LLMError("invalid_sql")
|
|
107
|
-
|
|
108
|
-
output_cache: dict[int, set[str] | None] = {}
|
|
109
|
-
|
|
110
|
-
def source_columns(
|
|
111
|
-
source: exp.Table | Scope, visiting: frozenset[int] = frozenset(),
|
|
112
|
-
) -> set[str] | None:
|
|
113
|
-
if isinstance(source, exp.Table):
|
|
114
|
-
name = _schema_reference_name(source.parts)
|
|
115
|
-
return table_columns[name] or None
|
|
116
|
-
scope_id = id(source)
|
|
117
|
-
if scope_id in output_cache:
|
|
118
|
-
return output_cache[scope_id]
|
|
119
|
-
if scope_id in visiting:
|
|
120
|
-
# Recursive projections cannot be grounded without additional
|
|
121
|
-
# type/lineage analysis. Fail closed instead of guessing.
|
|
122
|
-
raise LLMError("invalid_sql")
|
|
123
|
-
visiting = visiting | {scope_id}
|
|
124
|
-
columns: set[str] | None
|
|
125
|
-
if source.outer_columns:
|
|
126
|
-
columns = {name.casefold() for name in source.outer_columns}
|
|
127
|
-
elif source.union_scopes:
|
|
128
|
-
columns = source_columns(source.union_scopes[0], visiting)
|
|
129
|
-
else:
|
|
130
|
-
columns = set()
|
|
131
|
-
# Projection expansion expects a query scope; other scope nodes
|
|
132
|
-
# retain the existing fail-closed behavior in validate_llm_sql.
|
|
133
|
-
for projection in cast(exp.Query, source.expression).selects:
|
|
134
|
-
if not projection.is_star:
|
|
135
|
-
if projection.alias_or_name:
|
|
136
|
-
columns.add(projection.alias_or_name.casefold())
|
|
137
|
-
continue
|
|
138
|
-
projected_sources: Iterable[exp.Table | Scope]
|
|
139
|
-
if isinstance(projection, exp.Column):
|
|
140
|
-
qualifier = _schema_reference_name(projection.parts[:-1])
|
|
141
|
-
projected_sources = [resolve_source(source, qualifier)]
|
|
142
|
-
else:
|
|
143
|
-
projected_sources = sources_by_scope[scope_id].values()
|
|
144
|
-
for projected in projected_sources:
|
|
145
|
-
projected_columns = source_columns(projected, visiting)
|
|
146
|
-
if projected_columns is None:
|
|
147
|
-
output_cache[scope_id] = None
|
|
148
|
-
return None
|
|
149
|
-
columns.update(projected_columns)
|
|
150
|
-
output_cache[scope_id] = columns
|
|
151
|
-
return columns
|
|
152
|
-
|
|
153
|
-
for scope in scopes:
|
|
154
|
-
for column in scope.find_all(exp.Column):
|
|
155
|
-
if column.table:
|
|
156
|
-
qualifier = _schema_reference_name(column.parts[:-1])
|
|
157
|
-
source = resolve_source(scope, qualifier)
|
|
158
|
-
columns = source_columns(source)
|
|
159
|
-
if not column.is_star and columns is not None and column.name.casefold() not in columns:
|
|
160
|
-
raise LLMError("invalid_sql")
|
|
161
|
-
elif all_columns and column.name.casefold() not in all_columns:
|
|
162
|
-
raise LLMError("invalid_sql")
|
|
50
|
+
"""Compatibility wrapper for the shared schema reference validator."""
|
|
51
|
+
try:
|
|
52
|
+
validate_schema_references(
|
|
53
|
+
statement,
|
|
54
|
+
SchemaContext.from_mapping(schema),
|
|
55
|
+
scope_traverser=traverse_scope,
|
|
56
|
+
)
|
|
57
|
+
except Exception:
|
|
58
|
+
raise LLMError("invalid_sql") from None
|
|
163
59
|
|
|
164
60
|
|
|
165
61
|
def validate_llm_sql(
|