light-infer-chat commited on
Commit
e68a95d
·
1 Parent(s): 25bbb06
app/api/deps.py CHANGED
@@ -9,10 +9,15 @@ from app.services.database_service import DatabaseService
9
  from app.services.embeddings_service import EmbeddingService
10
  from app.services.extraction_service import ExtractionService
11
  from app.services.ocr_service import OCRService
 
12
  from app.services.text_cleaner_service import TextCleanerService
13
  from app.services.web_search_service import WebSearchService
14
 
15
 
 
 
 
 
16
  def get_text_cleaner_service() -> TextCleanerService:
17
  return TextCleanerService()
18
 
 
9
  from app.services.embeddings_service import EmbeddingService
10
  from app.services.extraction_service import ExtractionService
11
  from app.services.ocr_service import OCRService
12
+ from app.services.sql_validator_service import SqlValidatorService
13
  from app.services.text_cleaner_service import TextCleanerService
14
  from app.services.web_search_service import WebSearchService
15
 
16
 
17
+ def get_sql_validator_service() -> SqlValidatorService:
18
+ return SqlValidatorService()
19
+
20
+
21
  def get_text_cleaner_service() -> TextCleanerService:
22
  return TextCleanerService()
23
 
app/api/v1/router.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
 
3
  from fastapi import APIRouter
4
 
5
- from app.api.v1 import batch, chat, code_executor, convert, database, embeddings, reconcile, scraper, system, web_search
6
  from app.api.verify import router as verify_router
7
 
8
  api_v1_router = APIRouter()
@@ -16,4 +16,5 @@ api_v1_router.include_router(verify_router, prefix="/verify", tags=["Verify"])
16
  api_v1_router.include_router(reconcile.router, tags=["Reconcile"])
17
  api_v1_router.include_router(scraper.router, tags=["Web Scraping"])
18
  api_v1_router.include_router(web_search.router, tags=["Web Search"])
 
19
  api_v1_router.include_router(chat.router, tags=["Chat"])
 
2
 
3
  from fastapi import APIRouter
4
 
5
+ from app.api.v1 import batch, chat, code_executor, convert, database, embeddings, reconcile, scraper, sql_validator, system, web_search
6
  from app.api.verify import router as verify_router
7
 
8
  api_v1_router = APIRouter()
 
16
  api_v1_router.include_router(reconcile.router, tags=["Reconcile"])
17
  api_v1_router.include_router(scraper.router, tags=["Web Scraping"])
18
  api_v1_router.include_router(web_search.router, tags=["Web Search"])
19
+ api_v1_router.include_router(sql_validator.router, tags=["SQL Validator"])
20
  api_v1_router.include_router(chat.router, tags=["Chat"])
app/api/v1/sql_validator.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ from fastapi import APIRouter, Depends
6
+
7
+ from app.api.deps import require_auth
8
+ from app.models.schemas import SqlValidationRequest, SqlValidationResponse
9
+ from app.services.sql_validator_service import SqlValidatorService
10
+
11
+ router = APIRouter()
12
+ _service = SqlValidatorService()
13
+
14
+
15
+ @router.post(
16
+ "/sql/validate",
17
+ response_model=SqlValidationResponse,
18
+ summary="Validate a SQL query without executing it",
19
+ )
20
+ async def validate_sql(
21
+ body: SqlValidationRequest,
22
+ token: str = Depends(require_auth),
23
+ ):
24
+ start = time.perf_counter()
25
+ result = _service.validate(body.query, body.dialect)
26
+ elapsed_ms = round((time.perf_counter() - start) * 1000, 3)
27
+ return SqlValidationResponse(
28
+ success=True,
29
+ time_ms=elapsed_ms,
30
+ valid=result["valid"],
31
+ query_type=result["query_type"],
32
+ dialect=result["dialect"],
33
+ is_read_only=result["is_read_only"],
34
+ errors=result["errors"],
35
+ warnings=result["warnings"],
36
+ tables=result["tables"],
37
+ columns=result["columns"],
38
+ )
app/models/schemas.py CHANGED
@@ -428,3 +428,21 @@ class WebSearchEngineDescriptionsResponse(BaseModel):
428
  time_ms: float
429
  engines: Optional[Dict[str, Any]] = None
430
  error: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  time_ms: float
429
  engines: Optional[Dict[str, Any]] = None
430
  error: Optional[str] = None
431
+
432
+
433
+ class SqlValidationRequest(BaseModel):
434
+ query: str = Field(..., min_length=1, max_length=100000, description="SQL query string to validate")
435
+ dialect: Optional[str] = Field(None, description="SQL dialect (mysql, postgres, bigquery, snowflake, sqlite, etc.)")
436
+
437
+
438
+ class SqlValidationResponse(BaseModel):
439
+ success: bool
440
+ time_ms: float
441
+ valid: bool
442
+ query_type: str
443
+ dialect: Optional[str] = None
444
+ is_read_only: bool
445
+ errors: List[str] = []
446
+ warnings: List[str] = []
447
+ tables: List[str] = []
448
+ columns: List[str] = []
app/services/sql_validator_service.py ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from enum import Enum
5
+ from typing import Any, Optional
6
+
7
+ import sqlglot
8
+ from sqlglot import exp
9
+ from sqlglot.errors import ParseError
10
+
11
+
12
+ class QueryType(str, Enum):
13
+ SELECT = "SELECT"
14
+ INSERT = "INSERT"
15
+ UPDATE = "UPDATE"
16
+ DELETE = "DELETE"
17
+ CREATE = "CREATE"
18
+ ALTER = "ALTER"
19
+ DROP = "DROP"
20
+ TRUNCATE = "TRUNCATE"
21
+ MERGE = "MERGE"
22
+ WITH = "WITH"
23
+ CALL = "CALL"
24
+ EXPLAIN = "EXPLAIN"
25
+ UNKNOWN = "UNKNOWN"
26
+
27
+
28
+ _DIALECT_ALIASES: dict[str, Optional[str]] = {
29
+ "mysql": "mysql",
30
+ "mariadb": "mysql",
31
+ "postgres": "postgres",
32
+ "postgresql": "postgres",
33
+ "pg": "postgres",
34
+ "redshift": "redshift",
35
+ "cockroachdb": "cockroachdb",
36
+ "cockroach": "cockroachdb",
37
+ "crdb": "cockroachdb",
38
+ "sqlite": "sqlite",
39
+ "sqlserver": "tsql",
40
+ "mssql": "tsql",
41
+ "tsql": "tsql",
42
+ "oracle": "oracle",
43
+ "oracledb": "oracle",
44
+ "bigquery": "bigquery",
45
+ "gcp": "bigquery",
46
+ "bq": "bigquery",
47
+ "snowflake": "snowflake",
48
+ "sf": "snowflake",
49
+ "spark": "spark",
50
+ "hive": "hive",
51
+ "databricks": "databricks",
52
+ "duckdb": "duckdb",
53
+ "presto": "presto",
54
+ "trino": "trino",
55
+ "clickhouse": "clickhouse",
56
+ "ansi": None,
57
+ "standard": None,
58
+ "sql": None,
59
+ }
60
+
61
+ _READ_ONLY_TYPES: frozenset[QueryType] = frozenset(
62
+ {QueryType.SELECT, QueryType.WITH, QueryType.EXPLAIN}
63
+ )
64
+
65
+ _AST_TYPE_MAP: list[tuple[type[exp.Expression], QueryType]] = [
66
+ (exp.Select, QueryType.SELECT),
67
+ (exp.Insert, QueryType.INSERT),
68
+ (exp.Update, QueryType.UPDATE),
69
+ (exp.Delete, QueryType.DELETE),
70
+ (exp.Create, QueryType.CREATE),
71
+ (exp.Alter, QueryType.ALTER),
72
+ (exp.AlterColumn, QueryType.ALTER),
73
+ (exp.Drop, QueryType.DROP),
74
+ (exp.TruncateTable, QueryType.TRUNCATE),
75
+ (exp.Merge, QueryType.MERGE),
76
+ (exp.With, QueryType.WITH),
77
+ (exp.Command, QueryType.UNKNOWN),
78
+ ]
79
+
80
+ _WRITE_NODE_TYPES: tuple[type[exp.Expression], ...] = (
81
+ exp.Insert,
82
+ exp.Update,
83
+ exp.Delete,
84
+ exp.Create,
85
+ exp.Alter,
86
+ exp.AlterColumn,
87
+ exp.Drop,
88
+ exp.TruncateTable,
89
+ exp.Merge,
90
+ )
91
+
92
+ _PLACEHOLDER_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
93
+ ("positional_?", re.compile(r"\?")),
94
+ ("pyformat_%s", re.compile(r"%s")),
95
+ ("numeric_$n", re.compile(r"\$\d+")),
96
+ ("named_:param", re.compile(r"(?<![:\w]):(\w+)")),
97
+ ]
98
+
99
+
100
+ def _normalize_dialect(dialect: Optional[str]) -> Optional[str]:
101
+ if dialect is None:
102
+ return None
103
+ key = dialect.strip().lower()
104
+ return _DIALECT_ALIASES.get(key, key)
105
+
106
+
107
+ def _has_ctes(statement: exp.Expression) -> bool:
108
+ return statement.args.get("with") is not None
109
+
110
+
111
+ def _detect_query_type(statement: exp.Expression) -> QueryType:
112
+ if _has_ctes(statement):
113
+ return QueryType.WITH
114
+ for ast_type, query_type in _AST_TYPE_MAP:
115
+ if isinstance(statement, ast_type):
116
+ return query_type
117
+ type_name = type(statement).__name__.upper()
118
+ for qt in QueryType:
119
+ if qt != QueryType.UNKNOWN and qt.value in type_name:
120
+ return qt
121
+ return QueryType.UNKNOWN
122
+
123
+
124
+ def _is_read_only(statement: exp.Expression, query_type: QueryType) -> bool:
125
+ if query_type in _READ_ONLY_TYPES and query_type != QueryType.WITH:
126
+ return True
127
+ if query_type not in _READ_ONLY_TYPES:
128
+ return False
129
+ if query_type == QueryType.WITH:
130
+ for node in statement.find_all(_WRITE_NODE_TYPES):
131
+ return False
132
+ return True
133
+ return False
134
+
135
+
136
+ def _extract_tables(statement: exp.Expression) -> list[str]:
137
+ tables: list[str] = []
138
+ seen: set[str] = set()
139
+ for tbl in statement.find_all(exp.Table):
140
+ parts: list[str] = []
141
+ if tbl.catalog:
142
+ parts.append(tbl.catalog)
143
+ if tbl.db:
144
+ parts.append(tbl.db)
145
+ parts.append(tbl.name)
146
+ full_name = ".".join(p for p in parts if p)
147
+ if full_name and full_name not in seen:
148
+ seen.add(full_name)
149
+ tables.append(full_name)
150
+ return tables
151
+
152
+
153
+ def _extract_columns(statement: exp.Expression) -> list[str]:
154
+ columns: list[str] = []
155
+ seen: set[str] = set()
156
+ for col in statement.find_all(exp.Column):
157
+ name = col.name
158
+ if name and name not in seen:
159
+ seen.add(name)
160
+ columns.append(name)
161
+ return columns
162
+
163
+
164
+ def _detect_placeholders(query: str) -> list[str]:
165
+ stripped = re.sub(r"'(?:[^'\\]|\\.)*'", "", query)
166
+ stripped = re.sub(r'"(?:[^"\\]|\\.)*"', "", stripped)
167
+ detected: set[str] = set()
168
+ for name, pattern in _PLACEHOLDER_PATTERNS:
169
+ if pattern.search(stripped):
170
+ detected.add(name)
171
+ if detected:
172
+ return [
173
+ f"Prepared-statement placeholders detected: "
174
+ f"{', '.join(sorted(detected))}. Ensure the target "
175
+ f"database driver supports these placeholder styles."
176
+ ]
177
+ return []
178
+
179
+
180
+ def _check_best_practices(statement: exp.Expression) -> list[str]:
181
+ warnings: list[str] = []
182
+ for sel in statement.find_all(exp.Select):
183
+ if any(isinstance(e, exp.Star) for e in (sel.expressions or [])):
184
+ warnings.append(
185
+ "SELECT * detected — explicitly listing columns is "
186
+ "recommended for performance and maintainability."
187
+ )
188
+ break
189
+ if isinstance(statement, exp.Update) and not statement.args.get("where"):
190
+ warnings.append(
191
+ "UPDATE without a WHERE clause will affect every row in the table."
192
+ )
193
+ if isinstance(statement, exp.Delete) and not statement.args.get("where"):
194
+ warnings.append(
195
+ "DELETE without a WHERE clause will remove every row from the table."
196
+ )
197
+ for join in statement.find_all(exp.Join):
198
+ if (join.args.get("kind") or "").upper() == "NATURAL":
199
+ warnings.append(
200
+ "NATURAL JOIN can produce unexpected column matches — "
201
+ "prefer explicit JOIN conditions."
202
+ )
203
+ break
204
+ return warnings
205
+
206
+
207
+ def _analyze_statements(statements: list[exp.Expression]) -> dict[str, Any]:
208
+ all_tables: list[str] = []
209
+ tables_seen: set[str] = set()
210
+ all_columns: list[str] = []
211
+ columns_seen: set[str] = set()
212
+ all_warnings: list[str] = []
213
+ primary_type: QueryType = QueryType.UNKNOWN
214
+ is_read_only = True
215
+
216
+ for idx, stmt in enumerate(statements):
217
+ stmt_type = _detect_query_type(stmt)
218
+ if idx == 0:
219
+ primary_type = stmt_type
220
+ if not _is_read_only(stmt, stmt_type):
221
+ is_read_only = False
222
+ for t in _extract_tables(stmt):
223
+ if t not in tables_seen:
224
+ tables_seen.add(t)
225
+ all_tables.append(t)
226
+ for c in _extract_columns(stmt):
227
+ if c not in columns_seen:
228
+ columns_seen.add(c)
229
+ all_columns.append(c)
230
+ all_warnings.extend(_check_best_practices(stmt))
231
+ if isinstance(stmt, exp.Command):
232
+ cmd_verb = getattr(stmt, "name", "")
233
+ all_warnings.append(
234
+ f"Statement uses a {cmd_verb!r} command that could not be "
235
+ f"fully analysed. Syntax validation may be incomplete."
236
+ )
237
+
238
+ if len(statements) > 1:
239
+ all_warnings.append(
240
+ f"Multiple statements detected ({len(statements)} total). "
241
+ f"Results reflect the combined analysis of all statements."
242
+ )
243
+
244
+ return {
245
+ "query_type": primary_type.value,
246
+ "is_read_only": is_read_only,
247
+ "tables": all_tables,
248
+ "columns": all_columns,
249
+ "warnings": all_warnings,
250
+ }
251
+
252
+
253
+ class SqlValidatorService:
254
+ def validate(self, query: str, dialect: Optional[str] = None) -> dict[str, Any]:
255
+ if not isinstance(query, str):
256
+ raise TypeError(f"query must be a string, got {type(query).__name__}")
257
+ if dialect is not None and not isinstance(dialect, str):
258
+ raise TypeError(f"dialect must be a string or None, got {type(dialect).__name__}")
259
+
260
+ normalized_dialect = _normalize_dialect(dialect)
261
+ stripped_query = query.strip()
262
+
263
+ if not stripped_query:
264
+ return {
265
+ "valid": False,
266
+ "query_type": QueryType.UNKNOWN.value,
267
+ "dialect": normalized_dialect,
268
+ "errors": ["Empty query string provided."],
269
+ "warnings": [],
270
+ "is_read_only": False,
271
+ "tables": [],
272
+ "columns": [],
273
+ }
274
+
275
+ warnings: list[str] = _detect_placeholders(stripped_query)
276
+ statements: list[exp.Expression] = []
277
+ errors: list[str] = []
278
+
279
+ try:
280
+ parsed = sqlglot.parse(
281
+ stripped_query,
282
+ dialect=normalized_dialect,
283
+ error_level=sqlglot.ErrorLevel.RAISE,
284
+ )
285
+ statements = [s for s in parsed if s is not None]
286
+ except ParseError as exc:
287
+ errors.append(f"SQL syntax error: {exc}")
288
+ except RecursionError:
289
+ errors.append("Query is too deeply nested to parse — consider simplifying.")
290
+ except Exception as exc:
291
+ errors.append(f"Unexpected error during parsing: {exc}")
292
+
293
+ if errors:
294
+ try:
295
+ parsed = sqlglot.parse(
296
+ stripped_query,
297
+ dialect=normalized_dialect,
298
+ error_level=sqlglot.ErrorLevel.WARN,
299
+ )
300
+ statements = [s for s in parsed if s is not None]
301
+ except Exception:
302
+ statements = []
303
+
304
+ if not statements:
305
+ if not errors:
306
+ errors.append("No valid SQL statements found. The query may be empty or contain only comments.")
307
+ return {
308
+ "valid": False,
309
+ "query_type": QueryType.UNKNOWN.value,
310
+ "dialect": normalized_dialect,
311
+ "errors": errors,
312
+ "warnings": warnings,
313
+ "is_read_only": False,
314
+ "tables": [],
315
+ "columns": [],
316
+ }
317
+
318
+ analysis = _analyze_statements(statements)
319
+ warnings.extend(analysis["warnings"])
320
+
321
+ valid = len(errors) == 0
322
+ if valid and analysis["query_type"] == QueryType.UNKNOWN.value:
323
+ has_known_statement = any(
324
+ _detect_query_type(s) != QueryType.UNKNOWN for s in statements
325
+ )
326
+ if not has_known_statement:
327
+ valid = False
328
+ if not errors:
329
+ errors.append(
330
+ "Query could not be recognised as a valid SQL statement."
331
+ )
332
+
333
+ return {
334
+ "valid": valid,
335
+ "query_type": analysis["query_type"],
336
+ "dialect": normalized_dialect,
337
+ "errors": errors,
338
+ "warnings": warnings,
339
+ "is_read_only": analysis["is_read_only"],
340
+ "tables": analysis["tables"],
341
+ "columns": analysis["columns"],
342
+ }
requirements.txt CHANGED
@@ -23,6 +23,7 @@ torch==2.12.1
23
  einops
24
  spacy>=3.7.0
25
  phonenumbers>=8.13.0
 
26
  tiktoken>=0.9.0
27
 
28
  # Async database drivers
 
23
  einops
24
  spacy>=3.7.0
25
  phonenumbers>=8.13.0
26
+ sqlglot>=20.0.0
27
  tiktoken>=0.9.0
28
 
29
  # Async database drivers