text2sql-eval-toolkit 1.0.0__py3-none-any.whl

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 (28) hide show
  1. text2sql_eval_toolkit/__init__.py +107 -0
  2. text2sql_eval_toolkit/analysis/__init__.py +5 -0
  3. text2sql_eval_toolkit/analysis/error_analysis.py +335 -0
  4. text2sql_eval_toolkit/analysis/report_tools.py +719 -0
  5. text2sql_eval_toolkit/config_args.py +65 -0
  6. text2sql_eval_toolkit/data/__init__.py +4 -0
  7. text2sql_eval_toolkit/data/benchmarks.json +69 -0
  8. text2sql_eval_toolkit/data/test-benchmarks.json +69 -0
  9. text2sql_eval_toolkit/env_loader.py +55 -0
  10. text2sql_eval_toolkit/evaluation/__init__.py +26 -0
  11. text2sql_eval_toolkit/evaluation/evaluation_tools.py +759 -0
  12. text2sql_eval_toolkit/evaluation/llm_as_judge.py +90 -0
  13. text2sql_eval_toolkit/execution/__init__.py +5 -0
  14. text2sql_eval_toolkit/execution/execution_tools.py +1448 -0
  15. text2sql_eval_toolkit/execution/replace_select_tool.py +114 -0
  16. text2sql_eval_toolkit/inference/__init__.py +5 -0
  17. text2sql_eval_toolkit/inference/agentic_pipeline.py +2335 -0
  18. text2sql_eval_toolkit/inference/base_pipeline.py +11 -0
  19. text2sql_eval_toolkit/inference/baseline_llm_pipeline.py +372 -0
  20. text2sql_eval_toolkit/inference/inference_tools.py +769 -0
  21. text2sql_eval_toolkit/logging.py +54 -0
  22. text2sql_eval_toolkit/profiling/profiling_tools.py +185 -0
  23. text2sql_eval_toolkit/utils.py +302 -0
  24. text2sql_eval_toolkit-1.0.0.dist-info/METADATA +382 -0
  25. text2sql_eval_toolkit-1.0.0.dist-info/RECORD +28 -0
  26. text2sql_eval_toolkit-1.0.0.dist-info/WHEEL +5 -0
  27. text2sql_eval_toolkit-1.0.0.dist-info/licenses/LICENSE +201 -0
  28. text2sql_eval_toolkit-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1448 @@
1
+ #
2
+ # Copyright IBM Corp. 2025 - 2026
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+
6
+ """
7
+ This module provides functions to execute SQL queries against a database and augment prediction data with actual query results.
8
+
9
+ Functions for postgres DBs:
10
+ 1. `quote_mixed_case_columns(sql: str) -> str`:
11
+ - This function takes a SQL query string and ensures that any column names not in lowercase are enclosed in double quotes.
12
+ - This is necessary for PostgreSQL compatibility.
13
+
14
+ 2. `run_sql_and_get_dataframe(connection_string: str, schema_name: str, sql: str) -> pd.DataFrame`:
15
+ - Executes the provided SQL query on the database using the given connection string and schema.
16
+ - Returns the query results as a pandas DataFrame.
17
+
18
+ 3. `run_execution(benchmark_id: str)`:
19
+ - This function processes predictions for a specific benchmark, running each SQL query and augmenting the predictions with the actual dataframes.
20
+ - It retrieves the database engine information for the given benchmark_id, constructs the connection string, and loads prediction data from a JSON file.
21
+ - For each prediction, it attempts to execute the predicted SQL, catches exceptions, and stores either the resulting dataframe or an error message.
22
+ - Finally, it writes the updated predictions back to the JSON file.
23
+
24
+ Requirements:
25
+ - `os`
26
+ - `json`
27
+ - `pathlib`
28
+ - `pandas`
29
+ - `sqlalchemy`
30
+ - `sqlglot`
31
+ - `text2sql_eval_toolkit.utils` (assumed to provide `get_benchmark_info`)
32
+
33
+ Assumptions:
34
+ - The environment variable for the PostgreSQL connection string is set.
35
+ - The predictions file is in JSON format and contains a structure with model predictions, each having a 'predicted_sql' key.
36
+ - The database schema_name is correctly specified in the benchmark information.
37
+ - Supported database type is currently only PostgreSQL.
38
+
39
+ Assisted by watsonx Code Assistant
40
+ """
41
+
42
+ import asyncio
43
+ import asyncpg
44
+ import os
45
+ import importlib
46
+ import json
47
+ import pandas as pd
48
+ import re
49
+ import sqlite3
50
+ import time
51
+ from func_timeout import func_timeout, FunctionTimedOut
52
+ from io import StringIO
53
+ from pathlib import Path
54
+ from sqlglot import parse_one, exp
55
+ from tqdm.asyncio import tqdm_asyncio
56
+ from text2sql_eval_toolkit.utils import (
57
+ get_benchmark_info,
58
+ get_gt_sqls,
59
+ parse_dataframe,
60
+ BENCHMARKS_FILE,
61
+ )
62
+ from text2sql_eval_toolkit.execution.replace_select_tool import (
63
+ replace_select_for_logic_ex,
64
+ )
65
+ from text2sql_eval_toolkit.logging import get_logger
66
+ from urllib.parse import urlparse, parse_qs, unquote
67
+
68
+
69
+ _ibm_db_mod = None
70
+ _sqlalchemy_mod = None
71
+ _aiomysql_mod = None
72
+
73
+ logger = get_logger(__name__)
74
+
75
+
76
+ def _require_mysql_deps():
77
+ """Import MySQL dependencies only when needed; raise a helpful error if missing."""
78
+ global _sqlalchemy_mod, _aiomysql_mod
79
+
80
+ if _sqlalchemy_mod is None:
81
+ try:
82
+ _sqlalchemy_mod = importlib.import_module("sqlalchemy.ext.asyncio")
83
+ except ModuleNotFoundError as e:
84
+ raise RuntimeError(
85
+ "MySQL support is optional and not installed. "
86
+ "Install extras with: pip install sqlalchemy[asyncio] asyncio-mysql (or aiomysql)"
87
+ ) from e
88
+
89
+ # Also import the text function
90
+ try:
91
+ from sqlalchemy import text
92
+
93
+ return _sqlalchemy_mod, text
94
+ except ImportError as e:
95
+ raise RuntimeError(
96
+ "MySQL support is optional and not installed. "
97
+ "Install extras with: pip install sqlalchemy[asyncio] asyncio-mysql (or aiomysql)"
98
+ ) from e
99
+
100
+
101
+ def normalize_mysql_connection_string(
102
+ connection_string: str, db_id: str = None
103
+ ) -> tuple[str, dict]:
104
+ """
105
+ Normalize MySQL connection string and return connect_args.
106
+ Based on your working normalize_connection_string function.
107
+ """
108
+ import re
109
+ from urllib.parse import urlparse, urlunparse
110
+
111
+ connect_args = {}
112
+
113
+ # Handle mysql:// with SSL parameters like your example
114
+ if connection_string.startswith("mysql://"):
115
+ # For async, we'll try aiomysql first, then fall back to asyncio-mysql
116
+ # But follow your SSL handling pattern
117
+ connection_string = connection_string.replace(
118
+ "mysql://", "mysql+aiomysql://", 1
119
+ )
120
+
121
+ # Parse the URL to modify the database part only if db_id is provided
122
+ if db_id:
123
+ parsed = urlparse(connection_string)
124
+ # Replace the database part (path) with the db_id
125
+ # Remove leading slash and any existing database name
126
+ new_path = f"/{db_id}"
127
+ # Rebuild the URL with the new database name
128
+ connection_string = urlunparse(
129
+ (
130
+ parsed.scheme,
131
+ parsed.netloc,
132
+ new_path,
133
+ parsed.params,
134
+ parsed.query,
135
+ parsed.fragment,
136
+ )
137
+ )
138
+
139
+ # Handle SSL mode parameter conversion - remove problematic SSL params
140
+ if "sslMode=" in connection_string:
141
+ # Remove the sslMode parameter entirely and let driver handle SSL automatically
142
+ connection_string = re.sub(r"[&?]sslMode=[^&]*", "", connection_string)
143
+
144
+ # Determine if this is an IBM Cloud or other SSL-required connection
145
+ ssl_required = any(
146
+ domain in connection_string
147
+ for domain in ["databases.appdomain.cloud", "ssl=true", "sslMode="]
148
+ )
149
+
150
+ if ssl_required:
151
+ # For aiomysql, let's try a simpler SSL approach
152
+ # Just enable SSL without complex verification for IBM Cloud
153
+ import ssl
154
+
155
+ ssl_context = ssl.create_default_context()
156
+ ssl_context.check_hostname = False
157
+ ssl_context.verify_mode = ssl.CERT_NONE
158
+
159
+ connect_args = {"ssl": ssl_context}
160
+
161
+ return connection_string, connect_args
162
+
163
+
164
+ def quote_mysql_identifiers(sql: str) -> str:
165
+ """
166
+ Quote MySQL identifiers using backticks instead of double quotes.
167
+ MySQL uses backticks (`) for identifier quoting, not double quotes.
168
+ """
169
+ try:
170
+ tree = parse_one(sql)
171
+ except Exception as e:
172
+ logger.debug(f"Failed to parse SQL: {e}")
173
+ return sql
174
+
175
+ def quote_identifier_if_needed(identifier):
176
+ if isinstance(identifier, exp.Identifier):
177
+ name = identifier.name
178
+ # Check if identifier needs quoting (contains special chars, is reserved word, etc.)
179
+ if not identifier.args.get("quoted") and (
180
+ name != name.lower()
181
+ or any(char in name for char in [" ", "-", "."])
182
+ or name.upper()
183
+ in [
184
+ "ORDER",
185
+ "GROUP",
186
+ "SELECT",
187
+ "FROM",
188
+ "WHERE",
189
+ "JOIN",
190
+ ] # Add more reserved words as needed
191
+ ):
192
+ return exp.Identifier(this=name, quoted=True)
193
+ return identifier
194
+ elif isinstance(identifier, str):
195
+ if identifier != identifier.lower() or any(
196
+ char in identifier for char in [" ", "-", "."]
197
+ ):
198
+ return exp.Identifier(this=identifier, quoted=True)
199
+ return exp.Identifier(this=identifier)
200
+ return identifier
201
+
202
+ for node in tree.walk():
203
+ if isinstance(node, exp.Column):
204
+ node.set("this", quote_identifier_if_needed(node.this))
205
+ if node.table:
206
+ node.set("table", quote_identifier_if_needed(node.table))
207
+ elif isinstance(node, exp.Alias):
208
+ alias = node.args.get("alias")
209
+ if isinstance(alias, exp.Identifier):
210
+ node.set("alias", quote_identifier_if_needed(alias))
211
+
212
+ # Use MySQL dialect to generate SQL with backticks
213
+ return tree.sql(dialect="mysql")
214
+
215
+
216
+ async def run_sql_and_get_dataframe_mysql_async(
217
+ normalized_conn_str: str, connect_args: dict, db_id: str, sql: str, timeout: int = 90
218
+ ) -> pd.DataFrame:
219
+ """
220
+ Execute SQL query on MySQL using SQLAlchemy async engine with timeout.
221
+
222
+ Args:
223
+ normalized_conn_str: MySQL connection string
224
+ connect_args: Connection arguments
225
+ db_id: Database ID to connect to
226
+ sql: SQL query to execute
227
+ timeout: Query timeout in seconds (default: 90)
228
+
229
+ Returns:
230
+ pd.DataFrame: Query results
231
+
232
+ Raises:
233
+ asyncio.TimeoutError: If query execution exceeds timeout
234
+ """
235
+ sqlalchemy_async, text = _require_mysql_deps()
236
+
237
+ # If db_id is provided, create a new connection string for that specific database
238
+ if db_id:
239
+ final_conn_str, final_connect_args = normalize_mysql_connection_string(
240
+ normalized_conn_str.replace("mysql+aiomysql://", "mysql://"), db_id
241
+ )
242
+ else:
243
+ final_conn_str, final_connect_args = normalized_conn_str, connect_args
244
+
245
+ engine = sqlalchemy_async.create_async_engine(
246
+ final_conn_str,
247
+ pool_size=1,
248
+ max_overflow=0,
249
+ pool_pre_ping=True,
250
+ pool_recycle=3600,
251
+ echo=False,
252
+ connect_args=final_connect_args,
253
+ )
254
+
255
+ # Log query execution start
256
+ sql_preview = sql[:100] + "..." if len(sql) > 100 else sql
257
+ logger.debug(f"Executing MySQL query on db_id={db_id}: {sql_preview}")
258
+ start_time = time.perf_counter()
259
+
260
+ try:
261
+ # Wrap execution with timeout
262
+ async with asyncio.timeout(timeout):
263
+ async with engine.begin() as conn:
264
+ result = await conn.execute(text(sql))
265
+ if result.returns_rows:
266
+ rows = result.fetchall()
267
+ columns = list(result.keys())
268
+ data = [dict(zip(columns, row)) for row in rows]
269
+ else:
270
+ columns = []
271
+ data = []
272
+
273
+ elapsed = time.perf_counter() - start_time
274
+ logger.debug(f"Query completed in {elapsed:.2f}s, returned {len(data)} rows")
275
+
276
+ return await asyncio.to_thread(pd.DataFrame, data, columns=columns)
277
+ except asyncio.TimeoutError:
278
+ elapsed = time.perf_counter() - start_time
279
+ logger.error(f"Query timed out after {elapsed:.2f}s (limit: {timeout}s)")
280
+ raise
281
+ finally:
282
+ await engine.dispose()
283
+
284
+
285
+ async def mysql_run_execution_async(
286
+ connection_string: str,
287
+ predictions_path: Path | str,
288
+ max_concurrent_tasks: int = 16,
289
+ per_query_timeout_s: int = 90,
290
+ force_rerun: bool = False
291
+ ):
292
+ """
293
+ Execute SQL queries against MySQL database asynchronously.
294
+ Each prediction record can specify its own db_id for database selection.
295
+
296
+ Args:
297
+ connection_string: MySQL connection string (e.g., "mysql://user:pass@host:port/default_db")
298
+ predictions_path: Path to predictions JSON file
299
+ max_concurrent_tasks: Maximum number of concurrent database connections
300
+ per_query_timeout_s: Timeout for each SQL query in seconds (default: 90)
301
+ force_rerun: Force re-execution even if results exist
302
+ """
303
+ sqlalchemy_async, text = _require_mysql_deps()
304
+
305
+ with open(predictions_path, "r") as pf:
306
+ predictions_data = json.load(pf)
307
+
308
+ logger.debug(f"Original MySQL connection string: {connection_string}")
309
+
310
+ # Test the base connection first
311
+ try:
312
+ normalized_conn_str, connect_args = normalize_mysql_connection_string(
313
+ connection_string
314
+ )
315
+ logger.debug(f"Normalized MySQL connection string: {normalized_conn_str}")
316
+ logger.debug(f"MySQL connect_args: {connect_args}")
317
+
318
+ # Create a test engine to verify base connection
319
+ test_engine = sqlalchemy_async.create_async_engine(
320
+ normalized_conn_str,
321
+ pool_size=1,
322
+ max_overflow=0,
323
+ pool_pre_ping=True,
324
+ pool_recycle=3600,
325
+ echo=False,
326
+ connect_args=connect_args,
327
+ )
328
+
329
+ # Test the connection
330
+ logger.debug("Testing MySQL base connection...")
331
+ async with test_engine.begin() as conn:
332
+ result = await conn.execute(text("SELECT 1"))
333
+ test_result = result.fetchone()
334
+ logger.debug(f"MySQL base connection test successful: {test_result}")
335
+
336
+ await test_engine.dispose()
337
+
338
+ except Exception as e:
339
+ logger.error(f"Error connecting to MySQL database: {e}")
340
+ raise RuntimeError(f"Failed to connect to MySQL database: {e}") from e
341
+
342
+ semaphore = asyncio.Semaphore(max_concurrent_tasks)
343
+
344
+ # Counter and lock for tracking queries
345
+ query_count = 0
346
+ query_lock = asyncio.Lock()
347
+
348
+ async def run_sql_with_count(db_id: str, sql: str):
349
+ nonlocal query_count
350
+ df = await run_sql_and_get_dataframe_mysql_async(
351
+ normalized_conn_str, connect_args, db_id, sql, per_query_timeout_s
352
+ )
353
+ async with query_lock:
354
+ query_count += 1
355
+ return df
356
+
357
+ async def process_prediction(obj):
358
+ async with semaphore:
359
+ obj = json.loads(json.dumps(obj)) # deepcopy-safe
360
+
361
+ if "metadata" in obj and "sql" in obj["metadata"]:
362
+ obj["sql"] = obj["metadata"]["sql"]
363
+
364
+ # Get db_id from the prediction record, default to None (use connection string default)
365
+ record_db_id = obj.get("db_id")
366
+
367
+ gt_sqls = obj.get("sql")
368
+ if not isinstance(gt_sqls, list):
369
+ gt_sqls = [gt_sqls]
370
+
371
+ gt_dfs = []
372
+ for gt_sql in gt_sqls:
373
+ try:
374
+ gt_df = await run_sql_with_count(record_db_id, gt_sql)
375
+ gt_dfs.append(gt_df.to_json(orient="split"))
376
+ except asyncio.TimeoutError:
377
+ logger.error(f"GT SQL timed out after {per_query_timeout_s}s")
378
+ obj["gt_sql_execution_error"] = (
379
+ f"GT SQL timed out after {per_query_timeout_s}s"
380
+ )
381
+ # Continue to allow prediction execution even if GT times out
382
+ except Exception as e:
383
+ logger.error(f"Error running ground truth SQL: {e}")
384
+ logger.error(f"SQL: {gt_sql}")
385
+ raise e
386
+
387
+ obj["gt_df"] = gt_dfs if len(gt_dfs) > 1 else gt_dfs[0]
388
+ obj.pop("gt_sql_execution_error", None)
389
+
390
+ async def _run_and_store_df(
391
+ sql_key: str,
392
+ df_key: str,
393
+ error_key: str,
394
+ truncated_flag_key: str,
395
+ execution_time_key: str,
396
+ model_predictions: dict,
397
+ obj: dict,
398
+ run_sql_fn,
399
+ ):
400
+ sql = model_predictions.get(sql_key)
401
+ if not sql or (df_key in model_predictions and not force_rerun):
402
+ return
403
+
404
+ # Use MySQL-specific identifier quoting
405
+ sql = quote_mysql_identifiers(sql)
406
+ model_predictions[sql_key] = sql
407
+
408
+ try:
409
+ # Time the execution
410
+ execution_start = time.perf_counter()
411
+ df = await run_sql_fn(record_db_id, sql)
412
+ execution_end = time.perf_counter()
413
+ execution_time_ms = (execution_end - execution_start) * 1000
414
+
415
+ # Determine max ground truth row count
416
+ if isinstance(obj["gt_df"], list):
417
+ max_gt_rows = max(
418
+ pd.read_json(gt, orient="split").shape[0]
419
+ for gt in obj["gt_df"]
420
+ )
421
+ else:
422
+ max_gt_rows = pd.read_json(obj["gt_df"], orient="split").shape[
423
+ 0
424
+ ]
425
+
426
+ # Truncate if too many rows
427
+ if df.shape[0] - max_gt_rows >= 10:
428
+ df = df.head(max_gt_rows + 9)
429
+ model_predictions[truncated_flag_key] = True
430
+
431
+ model_predictions[df_key] = df.to_json(orient="split")
432
+ model_predictions[execution_time_key] = round(execution_time_ms, 2)
433
+ model_predictions.pop(error_key, None)
434
+
435
+ except asyncio.TimeoutError:
436
+ model_predictions[error_key] = (
437
+ f"Error running SQL: timed out after {per_query_timeout_s}s"
438
+ )
439
+ model_predictions[execution_time_key] = None
440
+ logger.debug(f"SQL timed out after {per_query_timeout_s}s")
441
+ except Exception as e:
442
+ model_predictions[error_key] = f"Error running SQL: {e}"
443
+ model_predictions[execution_time_key] = None
444
+ logger.debug(f"SQL error: {e}")
445
+
446
+ for model_name, model_predictions in obj.get("predictions", {}).items():
447
+ await _run_and_store_df(
448
+ sql_key="predicted_sql",
449
+ df_key="predicted_df",
450
+ error_key="sql_execution_error",
451
+ truncated_flag_key="predicted_df_truncated",
452
+ execution_time_key="execution_time_ms",
453
+ model_predictions=model_predictions,
454
+ obj=obj,
455
+ run_sql_fn=run_sql_with_count,
456
+ )
457
+ await _run_and_store_df(
458
+ sql_key="logic_sql",
459
+ df_key="logic_df",
460
+ error_key="logic_sql_execution_error",
461
+ truncated_flag_key="logic_df_truncated",
462
+ execution_time_key="logic_execution_time_ms",
463
+ model_predictions=model_predictions,
464
+ obj=obj,
465
+ run_sql_fn=run_sql_with_count,
466
+ )
467
+
468
+ return obj
469
+
470
+ tasks = [process_prediction(obj) for obj in predictions_data]
471
+ updated_predictions_data = await tqdm_asyncio.gather(
472
+ *tasks, desc="Executing MySQL SQL queries"
473
+ )
474
+
475
+ with open(predictions_path, "w") as pf:
476
+ json.dump(updated_predictions_data, pf, indent=4)
477
+
478
+ return query_count
479
+
480
+
481
+ def _require_ibm_db():
482
+ """Import ibm_db only when needed; raise a helpful error if missing."""
483
+ global _ibm_db_mod
484
+ if _ibm_db_mod is None:
485
+ try:
486
+ _ibm_db_mod = importlib.import_module("ibm_db")
487
+ except ModuleNotFoundError as e:
488
+ raise RuntimeError(
489
+ "DB2 support is optional and not installed. "
490
+ "Install extras with: pip install .[db2] (or) pip install ibm-db>=3.2.6"
491
+ ) from e
492
+ return _ibm_db_mod
493
+
494
+
495
+ def quote_mixed_case_columns(sql: str) -> str:
496
+ try:
497
+ tree = parse_one(sql)
498
+ except Exception as e:
499
+ logger.debug(f"Failed to parse SQL: {e}")
500
+ return sql
501
+
502
+ def quote_identifier_if_needed(identifier):
503
+ if isinstance(identifier, exp.Identifier):
504
+ name = identifier.name
505
+ if not identifier.args.get("quoted") and name != name.lower():
506
+ return exp.Identifier(this=name, quoted=True)
507
+ return identifier
508
+ elif isinstance(identifier, str):
509
+ if identifier != identifier.lower():
510
+ return exp.Identifier(this=identifier, quoted=True)
511
+ return exp.Identifier(this=identifier)
512
+ return identifier
513
+
514
+ for node in tree.walk():
515
+ if isinstance(node, exp.Column):
516
+ node.set("this", quote_identifier_if_needed(node.this))
517
+ if node.table:
518
+ node.set("table", quote_identifier_if_needed(node.table))
519
+ elif isinstance(node, exp.Alias):
520
+ alias = node.args.get("alias")
521
+ if isinstance(alias, exp.Identifier):
522
+ node.set("alias", quote_identifier_if_needed(alias))
523
+
524
+ return tree.sql(dialect="postgres")
525
+
526
+
527
+ async def run_sql_and_get_dataframe_async(
528
+ pool, schema_name: str, sql: str, timeout: int = 90
529
+ ) -> pd.DataFrame:
530
+ """
531
+ Execute SQL query on PostgreSQL with timeout.
532
+
533
+ Args:
534
+ pool: asyncpg connection pool
535
+ schema_name: PostgreSQL schema name
536
+ sql: SQL query to execute
537
+ timeout: Query timeout in seconds (default: 90)
538
+
539
+ Returns:
540
+ pd.DataFrame: Query results as DataFrame
541
+
542
+ Raises:
543
+ asyncio.TimeoutError: If query execution exceeds timeout
544
+ """
545
+ async def _execute_query():
546
+ async with pool.acquire() as conn:
547
+ # Set search_path for this connection
548
+ await conn.execute(f"SET search_path TO {schema_name}")
549
+ rows = await conn.fetch(sql)
550
+ if rows:
551
+ columns = rows[0].keys()
552
+ else:
553
+ columns = []
554
+ data = [dict(row) for row in rows]
555
+ # Use `to_thread` because DataFrame creation is not async
556
+ return await asyncio.to_thread(pd.DataFrame, data, columns=columns)
557
+
558
+ # Wrap execution with timeout
559
+ return await asyncio.wait_for(_execute_query(), timeout=timeout)
560
+
561
+
562
+ async def postgres_run_execution_async(
563
+ connection_string, schema_name, predictions_path, max_concurrent_tasks: int = 16, per_query_timeout_s: int = 90, force_rerun: bool = False
564
+ ):
565
+ """
566
+ Execute SQL queries on PostgreSQL using asyncpg with timeout.
567
+
568
+ Args:
569
+ connection_string: PostgreSQL connection string
570
+ schema_name: PostgreSQL schema name
571
+ predictions_path: Path to predictions JSON file
572
+ max_concurrent_tasks: Maximum number of concurrent database connections
573
+ per_query_timeout_s: Timeout for each SQL query in seconds (default: 90)
574
+ force_rerun: Force re-execution even if results exist
575
+
576
+ Returns:
577
+ int: Total number of queries executed
578
+ """
579
+ with open(predictions_path, "r") as pf:
580
+ predictions_data = json.load(pf)
581
+
582
+ # Set search_path using server_settings parameter
583
+ pool = await asyncpg.create_pool(
584
+ dsn=connection_string,
585
+ min_size=1,
586
+ max_size=max_concurrent_tasks,
587
+ server_settings={'search_path': schema_name}
588
+ )
589
+ semaphore = asyncio.Semaphore(max_concurrent_tasks)
590
+
591
+ # Counter and lock
592
+ query_count = 0
593
+ query_lock = asyncio.Lock()
594
+
595
+ async def run_sql_with_count(sql):
596
+ nonlocal query_count
597
+ df = await run_sql_and_get_dataframe_async(pool, schema_name, sql, per_query_timeout_s)
598
+ async with query_lock:
599
+ query_count += 1
600
+ return df
601
+
602
+ async def process_prediction(obj):
603
+ async with semaphore:
604
+ obj = json.loads(json.dumps(obj)) # deepcopy-safe
605
+
606
+ if "metadata" in obj and "sql" in obj["metadata"]:
607
+ obj["sql"] = obj["metadata"]["sql"]
608
+
609
+ gt_sqls = obj.get("sql")
610
+ if not isinstance(gt_sqls, list):
611
+ gt_sqls = [gt_sqls]
612
+
613
+ gt_dfs = []
614
+ for gt_sql in gt_sqls:
615
+ try:
616
+ gt_df = await run_sql_with_count(gt_sql)
617
+ gt_dfs.append(gt_df.to_json(orient="split"))
618
+ except asyncio.TimeoutError:
619
+ logger.error(f"GT SQL timed out after {per_query_timeout_s}s")
620
+ obj["gt_sql_execution_error"] = (
621
+ f"GT SQL timed out after {per_query_timeout_s}s"
622
+ )
623
+ except Exception as e:
624
+ logger.error(f"Error running ground truth SQL: {e}")
625
+ logger.error(f"SQL: {gt_sql}")
626
+ obj["gt_sql_execution_error"] = (
627
+ f"Error running ground truth SQL: {e}"
628
+ )
629
+ # Continue processing other queries instead of raising
630
+
631
+ if gt_dfs:
632
+ obj["gt_df"] = gt_dfs if len(gt_dfs) > 1 else gt_dfs[0]
633
+ obj.pop("gt_sql_execution_error", None)
634
+
635
+ async def _run_and_store_df(
636
+ sql_key: str,
637
+ df_key: str,
638
+ error_key: str,
639
+ truncated_flag_key: str,
640
+ execution_time_key: str,
641
+ model_predictions: dict,
642
+ obj: dict,
643
+ run_sql_fn,
644
+ ):
645
+ sql = model_predictions.get(sql_key)
646
+ if not sql or (df_key in model_predictions and not force_rerun):
647
+ return
648
+
649
+ sql = quote_mixed_case_columns(sql)
650
+ model_predictions[sql_key] = sql
651
+
652
+ try:
653
+ # Time the execution
654
+ execution_start = time.perf_counter()
655
+ df = await run_sql_fn(sql)
656
+ execution_end = time.perf_counter()
657
+ execution_time_ms = (execution_end - execution_start) * 1000
658
+
659
+ # Determine max ground truth row count
660
+ if isinstance(obj["gt_df"], list):
661
+ max_gt_rows = max(
662
+ pd.read_json(gt, orient="split").shape[0]
663
+ for gt in obj["gt_df"]
664
+ )
665
+ else:
666
+ max_gt_rows = pd.read_json(obj["gt_df"], orient="split").shape[
667
+ 0
668
+ ]
669
+
670
+ # Truncate if too many rows
671
+ if df.shape[0] - max_gt_rows >= 10:
672
+ df = df.head(max_gt_rows + 9)
673
+ model_predictions[truncated_flag_key] = True
674
+
675
+ model_predictions[df_key] = df.to_json(orient="split")
676
+ model_predictions[execution_time_key] = round(execution_time_ms, 2)
677
+ model_predictions.pop(error_key, None)
678
+
679
+ except asyncio.TimeoutError:
680
+ model_predictions[error_key] = (
681
+ f"Error running SQL: timed out after {per_query_timeout_s}s"
682
+ )
683
+ model_predictions[execution_time_key] = None
684
+ logger.debug(f"SQL timed out after {per_query_timeout_s}s")
685
+ except Exception as e:
686
+ model_predictions[error_key] = f"Error running SQL: {e}"
687
+ model_predictions[execution_time_key] = None
688
+ logger.debug(f"SQL error: {e}")
689
+
690
+ for model_name, model_predictions in obj.get("predictions", {}).items():
691
+ await _run_and_store_df(
692
+ sql_key="predicted_sql",
693
+ df_key="predicted_df",
694
+ error_key="sql_execution_error",
695
+ truncated_flag_key="predicted_df_truncated",
696
+ execution_time_key="execution_time_ms",
697
+ model_predictions=model_predictions,
698
+ obj=obj,
699
+ run_sql_fn=run_sql_with_count,
700
+ )
701
+ await _run_and_store_df(
702
+ sql_key="logic_sql",
703
+ df_key="logic_df",
704
+ error_key="logic_sql_execution_error",
705
+ truncated_flag_key="logic_df_truncated",
706
+ execution_time_key="logic_execution_time_ms",
707
+ model_predictions=model_predictions,
708
+ obj=obj,
709
+ run_sql_fn=run_sql_with_count,
710
+ )
711
+
712
+ return obj
713
+
714
+ tasks = [process_prediction(obj) for obj in predictions_data]
715
+ # updated_predictions_data = await asyncio.gather(*tasks)
716
+ updated_predictions_data = await tqdm_asyncio.gather(
717
+ *tasks, desc="Executing Postgres SQL queries"
718
+ )
719
+ await pool.close()
720
+
721
+ with open(predictions_path, "w") as pf:
722
+ json.dump(updated_predictions_data, pf, indent=4)
723
+
724
+ return query_count
725
+
726
+
727
+ def run_sqlite_query(db_path: str, sql: str) -> str:
728
+ conn = sqlite3.connect(db_path)
729
+ conn.text_factory = lambda b: b.decode(errors='replace')
730
+ conn.row_factory = sqlite3.Row
731
+ cursor = conn.execute(sql)
732
+ rows = cursor.fetchall()
733
+ columns = [col[0] for col in cursor.description]
734
+ data = [dict(zip(columns, row)) for row in rows]
735
+ conn.close()
736
+ return pd.DataFrame(data, columns=columns).to_json(orient="split")
737
+
738
+
739
+ async def run_sqlite_query_with_timeout(
740
+ db_path: Path, sql: str, timeout: int
741
+ ) -> pd.DataFrame:
742
+ loop = asyncio.get_running_loop()
743
+ try:
744
+ json_result = await loop.run_in_executor(
745
+ None,
746
+ lambda: func_timeout(timeout, run_sqlite_query, args=(str(db_path), sql)),
747
+ )
748
+ return pd.read_json(StringIO(json_result), orient="split")
749
+ except FunctionTimedOut:
750
+ raise asyncio.TimeoutError(f"Query timed out after {timeout} seconds")
751
+ except Exception as e:
752
+ raise RuntimeError(f"Error running query: {e}")
753
+
754
+
755
+ async def sqlite_run_execution_async(
756
+ db_folder,
757
+ predictions_path,
758
+ max_concurrent_tasks: int = 32,
759
+ sql_execution_timeout: int = 5,
760
+ force_rerun: bool = False,
761
+ ):
762
+ with open(predictions_path, "r") as pf:
763
+ predictions_data = json.load(pf)
764
+
765
+ semaphore = asyncio.Semaphore(max_concurrent_tasks)
766
+ query_count = 0
767
+
768
+ async def process_prediction(record):
769
+ nonlocal query_count
770
+ async with semaphore:
771
+ record = json.loads(json.dumps(record))
772
+ if "metadata" in record and "sql" in record["metadata"]:
773
+ record["sql"] = record["metadata"]["sql"]
774
+ db_id = record["db_id"]
775
+ db_filename = db_id + ".sqlite"
776
+ db_path = (
777
+ Path(BENCHMARKS_FILE).parent / Path(db_folder) / db_id / db_filename
778
+ )
779
+ if not db_path.exists():
780
+ raise ValueError(f"DB does not exist: {db_path}")
781
+
782
+ gt_sqls = get_gt_sqls(record)
783
+
784
+ gt_dfs = []
785
+ for gt_sql in gt_sqls:
786
+ try:
787
+ gt_df = await run_sqlite_query_with_timeout(
788
+ db_path, gt_sql, sql_execution_timeout
789
+ )
790
+ query_count += 1
791
+ gt_dfs.append(gt_df.to_json(orient="split"))
792
+ except asyncio.TimeoutError:
793
+ logger.error(f"Ground truth query timed out: {gt_sql}")
794
+ record["gt_sql_execution_error"] = "Ground truth query timed out."
795
+ except Exception as e:
796
+ logger.debug(f"DB path: {db_path}")
797
+ logger.error(f"Error running ground truth SQL: {e}")
798
+ logger.debug(f"Ground truth SQL with error: {gt_sql}")
799
+ record["gt_sql_execution_error"] = (
800
+ f"Error running ground truth SQL: {e}"
801
+ )
802
+ raise e
803
+ if len(gt_dfs) == 1:
804
+ record["gt_df"] = gt_dfs[0]
805
+ record.pop("gt_sql_execution_error", None)
806
+ elif len(gt_dfs) > 1:
807
+ record["gt_df"] = gt_dfs
808
+ record.pop("gt_sql_execution_error", None)
809
+
810
+ async def _run_sqlite_and_store(
811
+ sql_key: str,
812
+ df_key: str,
813
+ error_key: str,
814
+ truncated_flag_key: str,
815
+ execution_time_key: str,
816
+ model_predictions: dict,
817
+ record: dict,
818
+ db_path: str,
819
+ sql_execution_timeout: float,
820
+ query_count_ref: list,
821
+ ):
822
+ sql = model_predictions.get(sql_key)
823
+ if not sql or (df_key in model_predictions and not force_rerun):
824
+ return
825
+
826
+ try:
827
+ # Time the execution
828
+ execution_start = time.perf_counter()
829
+ df = await run_sqlite_query_with_timeout(
830
+ db_path, sql, sql_execution_timeout
831
+ )
832
+ execution_end = time.perf_counter()
833
+ execution_time_ms = (execution_end - execution_start) * 1000
834
+
835
+ query_count_ref[0] += 1
836
+ logger.debug(f"Finished running SQL query #{query_count_ref[0]}.")
837
+
838
+ max_gt_rows = 10
839
+ if "gt_df" in record:
840
+ if isinstance(record["gt_df"], list):
841
+ max_gt_rows = max(
842
+ pd.read_json(StringIO(gt), orient="split").shape[0]
843
+ for gt in record["gt_df"]
844
+ )
845
+ else:
846
+ max_gt_rows = pd.read_json(
847
+ StringIO(record["gt_df"]), orient="split"
848
+ ).shape[0]
849
+
850
+ if df.shape[0] - max_gt_rows >= 10:
851
+ df = df.head(max_gt_rows + 9)
852
+ model_predictions[truncated_flag_key] = True
853
+
854
+ model_predictions[df_key] = df.to_json(orient="split")
855
+ model_predictions[execution_time_key] = round(execution_time_ms, 2)
856
+ model_predictions.pop(error_key, None)
857
+
858
+ except asyncio.TimeoutError:
859
+ logger.info(f"{sql_key} query execution timed out: {sql}")
860
+ model_predictions[error_key] = (
861
+ f"{sql_key} query execution timed out: {sql}"
862
+ )
863
+ model_predictions[execution_time_key] = None
864
+ except Exception as e:
865
+ model_predictions[error_key] = f"Error running SQL: {e}"
866
+ model_predictions[execution_time_key] = None
867
+ logger.debug(f"{sql_key} error: {e}")
868
+
869
+ query_count_ref = [query_count] # wrap in list to mutate inside helper
870
+ for model_name, model_predictions in record.get("predictions", {}).items():
871
+ await _run_sqlite_and_store(
872
+ sql_key="predicted_sql",
873
+ df_key="predicted_df",
874
+ error_key="sql_execution_error",
875
+ truncated_flag_key="predicted_df_truncated",
876
+ execution_time_key="execution_time_ms",
877
+ model_predictions=model_predictions,
878
+ record=record,
879
+ db_path=db_path,
880
+ sql_execution_timeout=sql_execution_timeout,
881
+ query_count_ref=query_count_ref,
882
+ )
883
+ await _run_sqlite_and_store(
884
+ sql_key="logic_sql",
885
+ df_key="logic_df",
886
+ error_key="logic_sql_execution_error",
887
+ truncated_flag_key="logic_df_truncated",
888
+ execution_time_key="logic_execution_time_ms",
889
+ model_predictions=model_predictions,
890
+ record=record,
891
+ db_path=db_path,
892
+ sql_execution_timeout=sql_execution_timeout,
893
+ query_count_ref=query_count_ref,
894
+ )
895
+
896
+ return record
897
+
898
+ tasks = [process_prediction(obj) for obj in predictions_data]
899
+ # updated_predictions_data = await asyncio.gather(*tasks)
900
+ updated_predictions_data = await tqdm_asyncio.gather(
901
+ *tasks, desc="Executing SQLite SQL queries"
902
+ )
903
+
904
+ logger.debug("Writing updated predictions with execution dataframes to file...")
905
+ with open(predictions_path, "w") as pf:
906
+ json.dump(updated_predictions_data, pf, indent=4)
907
+ logger.debug("Finished writing.")
908
+
909
+ return query_count
910
+
911
+
912
+
913
+ _LIMIT_RE = re.compile(r"(?is)\s+LIMIT\s+(\d+)\s*$")
914
+
915
+
916
+ def _parse_db2_dsn(dsn: str) -> dict:
917
+ parts = [p for p in dsn.strip().strip(";").split(";") if p.strip()]
918
+ kv = {}
919
+ for p in parts:
920
+ if "=" in p:
921
+ k, v = p.split("=", 1)
922
+ kv[k.strip().upper()] = v.strip()
923
+ return kv
924
+
925
+
926
+ def _normalize_sql_for_db2(sql: str) -> str:
927
+ s = (sql or "").rstrip().rstrip(";")
928
+ m = _LIMIT_RE.search(s)
929
+ if m:
930
+ n = m.group(1)
931
+ s = _LIMIT_RE.sub(f" FETCH FIRST {n} ROWS ONLY", s)
932
+ return s
933
+
934
+
935
+ async def db2_run_execution_async(
936
+ connection_string: str,
937
+ schema_name: str | None,
938
+ predictions_path: Path | str,
939
+ max_concurrent_tasks: int = 16,
940
+ per_query_timeout_s: int = 90,
941
+ ):
942
+ with open(predictions_path, "r") as pf:
943
+ predictions_data = json.load(pf)
944
+
945
+ dsn_kv = _parse_db2_dsn(connection_string)
946
+ dsn_schema = dsn_kv.get("CURRENTSCHEMA")
947
+ effective_schema = schema_name or dsn_schema
948
+
949
+ semaphore = asyncio.Semaphore(max_concurrent_tasks)
950
+ query_count = 0
951
+ query_lock = asyncio.Lock()
952
+
953
+ def _run_sql_and_get_dataframe_db2(sql: str) -> pd.DataFrame:
954
+ ibm_db = _require_ibm_db() # lazy import
955
+ fixed_sql = _normalize_sql_for_db2(sql)
956
+
957
+ conn = ibm_db.connect(connection_string, "", "")
958
+ try:
959
+ try:
960
+ ibm_db.autocommit(conn, ibm_db.SQL_AUTOCOMMIT_ON)
961
+ except Exception:
962
+ pass
963
+
964
+ if effective_schema:
965
+ ibm_db.exec_immediate(conn, f"SET CURRENT SCHEMA {effective_schema}")
966
+
967
+ stmt = ibm_db.prepare(conn, fixed_sql)
968
+ try:
969
+ ibm_db.set_option(
970
+ stmt, {ibm_db.SQL_ATTR_QUERY_TIMEOUT: per_query_timeout_s}, 0
971
+ )
972
+ except Exception:
973
+ pass
974
+
975
+ ok = ibm_db.execute(stmt)
976
+ rows, cols = [], []
977
+ if ok and ibm_db.num_fields(stmt) > 0:
978
+ ncols = ibm_db.num_fields(stmt)
979
+ cols = [ibm_db.field_name(stmt, i) for i in range(ncols)]
980
+ tup = ibm_db.fetch_tuple(stmt)
981
+ while tup:
982
+ rows.append(tup)
983
+ tup = ibm_db.fetch_tuple(stmt)
984
+
985
+ ibm_db.free_stmt(stmt)
986
+ return pd.DataFrame(rows, columns=cols)
987
+ finally:
988
+ ibm_db.close(conn)
989
+
990
+ async def run_sql_and_get_dataframe_async_db2(sql: str) -> pd.DataFrame:
991
+ return await asyncio.wait_for(
992
+ asyncio.to_thread(_run_sql_and_get_dataframe_db2, sql),
993
+ timeout=per_query_timeout_s + 5,
994
+ )
995
+
996
+ async def run_sql_with_count(sql: str) -> pd.DataFrame:
997
+ nonlocal query_count
998
+ df = await run_sql_and_get_dataframe_async_db2(sql)
999
+ async with query_lock:
1000
+ query_count += 1
1001
+ return df
1002
+
1003
+ async def process_prediction(obj: dict):
1004
+ async with semaphore:
1005
+ obj = json.loads(json.dumps(obj)) # deepcopy-safe
1006
+
1007
+ if "metadata" in obj and "sql" in obj["metadata"]:
1008
+ obj["sql"] = obj["metadata"]["sql"]
1009
+
1010
+ gt_sqls = obj.get("sql")
1011
+ if not isinstance(gt_sqls, list):
1012
+ gt_sqls = [gt_sqls]
1013
+
1014
+ gt_dfs = []
1015
+ for gt_sql in gt_sqls:
1016
+ try:
1017
+ gt_df = await run_sql_with_count(gt_sql)
1018
+ gt_dfs.append(gt_df.to_json(orient="split"))
1019
+ except asyncio.TimeoutError:
1020
+ logger.error(f"GT SQL timed out after {per_query_timeout_s}s")
1021
+ obj["gt_sql_execution_error"] = (
1022
+ f"GT SQL timed out after {per_query_timeout_s}s"
1023
+ )
1024
+ # raise
1025
+ except Exception as e:
1026
+ logger.error(f"Error running ground truth SQL: {e}")
1027
+ logger.error(f"SQL: {gt_sql}")
1028
+ obj["gt_sql_execution_error"] = (
1029
+ f"Error running ground truth SQL: {e}"
1030
+ )
1031
+ # raise
1032
+ if len(gt_dfs) > 0:
1033
+ obj["gt_df"] = gt_dfs if len(gt_dfs) > 1 else gt_dfs[0]
1034
+ obj.pop("gt_sql_execution_error", None)
1035
+
1036
+ async def _run_and_store_df(
1037
+ sql_key: str,
1038
+ df_key: str,
1039
+ error_key: str,
1040
+ truncated_flag_key: str,
1041
+ execution_time_key: str,
1042
+ model_predictions: dict,
1043
+ obj: dict,
1044
+ run_sql_fn,
1045
+ ):
1046
+ sql = model_predictions.get(sql_key)
1047
+ if not sql or df_key in model_predictions:
1048
+ return
1049
+
1050
+ sql = quote_mixed_case_columns(sql)
1051
+ model_predictions[sql_key] = sql
1052
+
1053
+ try:
1054
+ # Time the execution
1055
+ execution_start = time.perf_counter()
1056
+ df = await run_sql_fn(sql)
1057
+ execution_end = time.perf_counter()
1058
+ execution_time_ms = (execution_end - execution_start) * 1000
1059
+
1060
+ if isinstance(obj["gt_df"], list):
1061
+ max_gt_rows = max(
1062
+ pd.read_json(gt, orient="split").shape[0]
1063
+ for gt in obj["gt_df"]
1064
+ )
1065
+ else:
1066
+ max_gt_rows = pd.read_json(obj["gt_df"], orient="split").shape[
1067
+ 0
1068
+ ]
1069
+
1070
+ if df.shape[0] - max_gt_rows >= 10:
1071
+ df = df.head(max_gt_rows + 9)
1072
+ model_predictions[truncated_flag_key] = True
1073
+
1074
+ model_predictions[df_key] = df.to_json(orient="split")
1075
+ model_predictions[execution_time_key] = round(execution_time_ms, 2)
1076
+ model_predictions.pop(error_key, None)
1077
+
1078
+ except asyncio.TimeoutError:
1079
+ model_predictions[error_key] = (
1080
+ f"Error running SQL: timed out after {per_query_timeout_s}s"
1081
+ )
1082
+ model_predictions[execution_time_key] = None
1083
+ except Exception as e:
1084
+ model_predictions[error_key] = f"Error running SQL: {e}"
1085
+ model_predictions[execution_time_key] = None
1086
+ logger.debug(f"SQL error: {e}")
1087
+
1088
+ for _, model_predictions in obj.get("predictions", {}).items():
1089
+ await _run_and_store_df(
1090
+ sql_key="predicted_sql",
1091
+ df_key="predicted_df",
1092
+ error_key="sql_execution_error",
1093
+ truncated_flag_key="predicted_df_truncated",
1094
+ execution_time_key="execution_time_ms",
1095
+ model_predictions=model_predictions,
1096
+ obj=obj,
1097
+ run_sql_fn=run_sql_with_count,
1098
+ )
1099
+ await _run_and_store_df(
1100
+ sql_key="logic_sql",
1101
+ df_key="logic_df",
1102
+ error_key="logic_sql_execution_error",
1103
+ truncated_flag_key="logic_df_truncated",
1104
+ execution_time_key="logic_execution_time_ms",
1105
+ model_predictions=model_predictions,
1106
+ obj=obj,
1107
+ run_sql_fn=run_sql_with_count,
1108
+ )
1109
+
1110
+ return obj
1111
+
1112
+ tasks = [process_prediction(obj) for obj in predictions_data]
1113
+ updated_predictions_data = await tqdm_asyncio.gather(
1114
+ *tasks, desc="Executing DB2 SQL queries"
1115
+ )
1116
+ with open(predictions_path, "w") as pf:
1117
+ json.dump(updated_predictions_data, pf, indent=4)
1118
+ return query_count
1119
+
1120
+
1121
+ def _parse_presto_sqlalchemy_url(conn_str: str) -> dict:
1122
+ """
1123
+ Parse a SQLAlchemy-style Presto URL into prestodb.dbapi.connect(**kwargs) args.
1124
+
1125
+ Example input:
1126
+ presto://user:pass@host:30624/catalog/schema?currentSchema=schema
1127
+ """
1128
+ u = urlparse(conn_str)
1129
+ username = unquote(u.username or "")
1130
+ password = unquote(u.password or "")
1131
+ host = u.hostname
1132
+ port = u.port or 443 # default to 443 for https
1133
+ # path like "/catalog/schema"
1134
+ path_parts = [p for p in (u.path or "").split("/") if p]
1135
+ catalog = path_parts[0] if len(path_parts) >= 1 else None
1136
+ schema = path_parts[1] if len(path_parts) >= 2 else None
1137
+
1138
+ qs = parse_qs(u.query or "")
1139
+ # Allow overriding schema via ?currentSchema=...
1140
+ if "currentSchema" in qs and qs["currentSchema"]:
1141
+ schema = qs["currentSchema"][0]
1142
+
1143
+ # Build BasicAuthentication if password is provided
1144
+ import prestodb # local import to keep optional
1145
+
1146
+ auth = prestodb.auth.BasicAuthentication(username, password) if password else None
1147
+
1148
+ # Filter out Nones
1149
+ kwargs = {
1150
+ "host": host,
1151
+ "port": port,
1152
+ "user": username,
1153
+ "catalog": catalog,
1154
+ "schema": schema,
1155
+ "http_scheme": "https",
1156
+ "auth": auth,
1157
+ "source": "text2sql-eval",
1158
+ }
1159
+ return {k: v for k, v in kwargs.items() if v is not None}
1160
+
1161
+
1162
+ async def presto_run_execution_async(
1163
+ connection_string: str,
1164
+ predictions_path: Path | str,
1165
+ max_concurrent_tasks: int = 16,
1166
+ per_query_timeout_s: int = 300,
1167
+ ):
1168
+ """
1169
+ Execute SQL queries on Presto (IBM Lakehouse) using prestodb.dbapi with BasicAuthentication.
1170
+ - connection_string: SQLAlchemy-style Presto URL
1171
+ - predictions_path: JSON file with records and model predictions
1172
+ - max_concurrent_tasks: concurrency bound
1173
+ - per_query_timeout_s: per-query timeout (client-side)
1174
+ """
1175
+ import prestodb
1176
+ import pandas as pd
1177
+ import asyncio
1178
+ import json
1179
+
1180
+ # Parse once and reuse for all queries
1181
+ base_connect_kwargs = _parse_presto_sqlalchemy_url(connection_string)
1182
+ logger.debug(
1183
+ f"Presto connect kwargs (redacted): "
1184
+ f"{ {k: ('***' if k == 'auth' else v) for k, v in base_connect_kwargs.items()} }"
1185
+ )
1186
+
1187
+ # Simple connectivity check
1188
+ try:
1189
+
1190
+ def _ping():
1191
+ conn = prestodb.dbapi.connect(**base_connect_kwargs)
1192
+ cur = conn.cursor()
1193
+ cur.execute("SELECT 1")
1194
+ cur.fetchall()
1195
+ cur.close()
1196
+ conn.close()
1197
+
1198
+ await asyncio.wait_for(asyncio.to_thread(_ping), timeout=30)
1199
+ logger.debug("Presto connectivity check succeeded.")
1200
+ except Exception as e:
1201
+ logger.error(f"Failed Presto connectivity check: {e}")
1202
+ raise RuntimeError(f"Failed to connect to Presto: {e}") from e
1203
+
1204
+ # Helper: run one SQL and return DataFrame (threaded, with timeout)
1205
+ def _run_presto_sql_to_df(sql: str) -> pd.DataFrame:
1206
+ conn = prestodb.dbapi.connect(**base_connect_kwargs)
1207
+ try:
1208
+ cur = conn.cursor()
1209
+ cur.execute(sql)
1210
+ rows = cur.fetchall() or []
1211
+ cols = [d[0] for d in (cur.description or [])]
1212
+ cur.close()
1213
+ return pd.DataFrame(rows, columns=cols)
1214
+ finally:
1215
+ conn.close()
1216
+
1217
+ async def run_sql_and_get_dataframe_presto(sql: str) -> pd.DataFrame:
1218
+ # client-side timeout guard; Presto may continue server-side even if we time out here
1219
+ return await asyncio.wait_for(
1220
+ asyncio.to_thread(_run_presto_sql_to_df, sql),
1221
+ timeout=per_query_timeout_s,
1222
+ )
1223
+
1224
+ # Orchestrate over predictions
1225
+ with open(predictions_path, "r") as pf:
1226
+ predictions_data = json.load(pf)
1227
+
1228
+ semaphore = asyncio.Semaphore(max_concurrent_tasks)
1229
+
1230
+ # Shared counter + lock
1231
+ query_count = 0
1232
+ query_lock = asyncio.Lock()
1233
+
1234
+ async def run_sql_with_count(sql: str) -> pd.DataFrame:
1235
+ nonlocal query_count
1236
+ df = await run_sql_and_get_dataframe_presto(sql)
1237
+ async with query_lock:
1238
+ query_count += 1
1239
+ return df
1240
+
1241
+ async def process_record(obj: dict):
1242
+ async with semaphore:
1243
+ obj = json.loads(json.dumps(obj)) # deep-copy-safe
1244
+
1245
+ # Normalize where GT SQL lives
1246
+ if "metadata" in obj and "sql" in obj["metadata"]:
1247
+ obj["sql"] = obj["metadata"]["sql"]
1248
+
1249
+ # Get GT sql(s)
1250
+ gt_sqls = get_gt_sqls(obj) if callable(get_gt_sqls) else obj.get("sql")
1251
+ if not isinstance(gt_sqls, list):
1252
+ gt_sqls = [gt_sqls]
1253
+
1254
+ # Ground truth execution
1255
+ gt_dfs = []
1256
+ for gt_sql in gt_sqls:
1257
+ if not gt_sql:
1258
+ continue
1259
+ try:
1260
+ df = await run_sql_with_count(gt_sql)
1261
+ gt_dfs.append(df.to_json(orient="split"))
1262
+ except asyncio.TimeoutError:
1263
+ logger.error(f"GT SQL timed out after {per_query_timeout_s}s")
1264
+ obj["gt_sql_execution_error"] = (
1265
+ f"GT SQL timed out after {per_query_timeout_s}s"
1266
+ )
1267
+ except Exception as e:
1268
+ logger.error(f"Error running GT SQL: {e}")
1269
+ logger.debug(f"GT SQL was: {gt_sql}")
1270
+ obj["gt_sql_execution_error"] = (
1271
+ f"Error running ground truth SQL: {e}"
1272
+ )
1273
+ # Keep going, or:
1274
+ # raise e
1275
+
1276
+ if gt_dfs:
1277
+ obj["gt_df"] = gt_dfs if len(gt_dfs) > 1 else gt_dfs[0]
1278
+ obj.pop("gt_sql_execution_error", None)
1279
+
1280
+ # Helper for model SQLs
1281
+ async def _run_and_store_df(
1282
+ sql_key: str,
1283
+ df_key: str,
1284
+ error_key: str,
1285
+ truncated_flag_key: str,
1286
+ execution_time_key: str,
1287
+ model_predictions: dict,
1288
+ obj_ref: dict,
1289
+ ):
1290
+ sql = model_predictions.get(sql_key)
1291
+ if not sql or df_key in model_predictions:
1292
+ return
1293
+
1294
+ # Quote mixed-case identifiers for Presto (double-quotes)
1295
+ sql = quote_mixed_case_columns(sql)
1296
+ model_predictions[sql_key] = sql
1297
+
1298
+ try:
1299
+ # Time the execution
1300
+ execution_start = time.perf_counter()
1301
+ df = await run_sql_with_count(sql)
1302
+ execution_end = time.perf_counter()
1303
+ execution_time_ms = (execution_end - execution_start) * 1000
1304
+
1305
+ # Establish reference size from GT (max of multiple GTs if present)
1306
+ max_gt_rows = 0
1307
+ if "gt_df" in obj_ref and obj_ref["gt_df"]:
1308
+ if isinstance(obj_ref["gt_df"], list):
1309
+ max_gt_rows = max(
1310
+ pd.read_json(gt_json, orient="split").shape[0]
1311
+ for gt_json in obj_ref["gt_df"]
1312
+ )
1313
+ else:
1314
+ max_gt_rows = pd.read_json(
1315
+ obj_ref["gt_df"], orient="split"
1316
+ ).shape[0]
1317
+
1318
+ # Truncate if too many extra rows
1319
+ if max_gt_rows and (df.shape[0] - max_gt_rows >= 10):
1320
+ df = df.head(max_gt_rows + 9)
1321
+ model_predictions[truncated_flag_key] = True
1322
+
1323
+ model_predictions[df_key] = df.to_json(orient="split")
1324
+ model_predictions[execution_time_key] = round(execution_time_ms, 2)
1325
+ model_predictions.pop(error_key, None)
1326
+
1327
+ except asyncio.TimeoutError:
1328
+ model_predictions[error_key] = (
1329
+ f"Error running SQL: timed out after {per_query_timeout_s}s"
1330
+ )
1331
+ model_predictions[execution_time_key] = None
1332
+ except Exception as e:
1333
+ model_predictions[error_key] = f"Error running SQL: {e}"
1334
+ model_predictions[execution_time_key] = None
1335
+ logger.debug(f"{sql_key} error: {e}")
1336
+
1337
+ # Execute model predictions
1338
+ for _, model_predictions in obj.get("predictions", {}).items():
1339
+ await _run_and_store_df(
1340
+ sql_key="predicted_sql",
1341
+ df_key="predicted_df",
1342
+ error_key="sql_execution_error",
1343
+ truncated_flag_key="predicted_df_truncated",
1344
+ execution_time_key="execution_time_ms",
1345
+ model_predictions=model_predictions,
1346
+ obj_ref=obj,
1347
+ )
1348
+ await _run_and_store_df(
1349
+ sql_key="logic_sql",
1350
+ df_key="logic_df",
1351
+ error_key="logic_sql_execution_error",
1352
+ truncated_flag_key="logic_df_truncated",
1353
+ execution_time_key="logic_execution_time_ms",
1354
+ model_predictions=model_predictions,
1355
+ obj_ref=obj,
1356
+ )
1357
+
1358
+ return obj
1359
+
1360
+ tasks = [process_record(obj) for obj in predictions_data]
1361
+ updated_predictions_data = await tqdm_asyncio.gather(
1362
+ *tasks, desc="Executing Presto SQL queries"
1363
+ )
1364
+
1365
+ with open(predictions_path, "w") as pf:
1366
+ json.dump(updated_predictions_data, pf, indent=4)
1367
+
1368
+ return query_count
1369
+
1370
+
1371
+ # For running from script
1372
+ def run_execution(benchmark_id: str, num_threads: int = 16, force_rerun: bool = False):
1373
+ benchmark_info = get_benchmark_info(benchmark_id)
1374
+ predictions_path = Path(benchmark_info["predictions_path"])
1375
+ db_engine = benchmark_info["db_engine"]
1376
+
1377
+ replace_select_for_logic_ex(predictions_path, db_engine)
1378
+
1379
+ if db_engine["db_type"] not in [
1380
+ "postgres",
1381
+ "sqlite",
1382
+ "db2",
1383
+ "mysql",
1384
+ "presto",
1385
+ ]:
1386
+ raise NotImplementedError(f"Unsupported DB type '{db_engine['db_type']}'.")
1387
+
1388
+ query_count = 0
1389
+
1390
+ if db_engine["db_type"] == "postgres":
1391
+ schema_name = db_engine.get("schema_name")
1392
+ connection_string = os.getenv(db_engine["connection_string_env_var"])
1393
+ if not connection_string:
1394
+ raise ValueError("Missing connection string.")
1395
+
1396
+ # Optional: get query timeout from config
1397
+ query_timeout = db_engine.get("query_timeout", 90)
1398
+
1399
+ query_count = asyncio.run(
1400
+ postgres_run_execution_async(
1401
+ connection_string, schema_name, predictions_path, num_threads, query_timeout, force_rerun
1402
+ )
1403
+ )
1404
+
1405
+ elif db_engine["db_type"] == "sqlite":
1406
+ db_folder = benchmark_info["db_engine"]["db_folder"]
1407
+ query_count = asyncio.run(
1408
+ sqlite_run_execution_async(db_folder, predictions_path, force_rerun=force_rerun)
1409
+ )
1410
+
1411
+ elif db_engine["db_type"] == "db2":
1412
+ schema_name = db_engine.get("schema_name")
1413
+ connection_string = os.getenv(db_engine["connection_string_env_var"])
1414
+ if not connection_string:
1415
+ raise ValueError("Missing DB2 connection string.")
1416
+ query_count = asyncio.run(
1417
+ db2_run_execution_async(
1418
+ connection_string, schema_name, predictions_path, num_threads, force_rerun
1419
+ )
1420
+ )
1421
+ elif db_engine["db_type"] == "mysql":
1422
+ connection_string = os.getenv(db_engine["connection_string_env_var"])
1423
+ if not connection_string:
1424
+ raise ValueError("Missing MySQL connection string.")
1425
+
1426
+ # Optional: get query timeout from config
1427
+ query_timeout = db_engine.get("query_timeout", 90)
1428
+
1429
+ query_count = asyncio.run(
1430
+ mysql_run_execution_async(
1431
+ connection_string, predictions_path, num_threads, query_timeout, force_rerun
1432
+ )
1433
+ )
1434
+ elif db_engine["db_type"] == "presto":
1435
+ connection_string = os.getenv(db_engine["connection_string_env_var"])
1436
+ if not connection_string:
1437
+ raise ValueError("Missing Presto connection string.")
1438
+
1439
+ # Optional: get query timeout from config
1440
+ query_timeout = db_engine.get("query_timeout", 300)
1441
+
1442
+ query_count = asyncio.run(
1443
+ presto_run_execution_async(
1444
+ connection_string, predictions_path, num_threads, query_timeout
1445
+ )
1446
+ )
1447
+
1448
+ logger.info(f"Total SQL queries executed: {query_count}")