Soumik Bose commited on
Commit
865d7db
·
1 Parent(s): 299cfb5
Files changed (2) hide show
  1. controller.py +173 -214
  2. requirements.txt +3 -1
controller.py CHANGED
@@ -24,12 +24,15 @@ from anyio import to_thread
24
  from dotenv import load_dotenv
25
  from pydantic import BaseModel
26
 
27
- # --- Database Drivers ---
 
 
 
 
 
28
  from bson import ObjectId
29
  import mysql.connector
30
- from mysql.connector import pooling
31
  import psycopg2
32
- from psycopg2 import pool as pg_pool
33
  from psycopg2.extras import RealDictCursor
34
  import uvicorn
35
 
@@ -53,6 +56,72 @@ logging.basicConfig(
53
  )
54
  logger = logging.getLogger("API_Controller")
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  def get_dynamic_thread_limit():
57
  """Calculates a safe thread limit based on available RAM."""
58
  try:
@@ -64,7 +133,6 @@ def get_dynamic_thread_limit():
64
  BYTES_PER_THREAD = 8 * 1024 * 1024
65
  calculated_limit = int(safe_ram_pool / BYTES_PER_THREAD)
66
  final_limit = max(100, min(calculated_limit, 3000))
67
-
68
  logger.info(f"Dynamic Limit Config: {total_ram_bytes/(1024**3):.2f}GB RAM / {num_workers} Workers. Limit: {final_limit}")
69
  return final_limit
70
  except Exception as e:
@@ -73,25 +141,23 @@ def get_dynamic_thread_limit():
73
 
74
  @asynccontextmanager
75
  async def lifespan(app: FastAPI):
 
76
  safe_limit = get_dynamic_thread_limit()
77
  to_thread.current_default_thread_limiter().total_tokens = safe_limit
78
- logger.info(f"Worker Process Started: Thread pool capacity set to {safe_limit}.")
79
  yield
 
 
80
 
81
  app = FastAPI(title="Unified Data Executor API", lifespan=lifespan)
82
 
83
  # ==============================================================================
84
- # BATCH MODELS
85
  # ==============================================================================
86
  T = TypeVar("T")
 
 
87
 
88
- class BatchRequest(BaseModel, Generic[T]):
89
- requests: List[T]
90
-
91
- class BatchResponse(BaseModel, Generic[T]):
92
- responses: List[T]
93
-
94
- # --- Directory & CORS ---
95
  CHART_DIR = "generated_charts"
96
  os.makedirs(CHART_DIR, exist_ok=True)
97
 
@@ -106,7 +172,6 @@ app.add_middleware(
106
  allow_headers=["*"],
107
  )
108
 
109
- # --- Security ---
110
  security = HTTPBearer()
111
  API_SECRET_TOKEN = os.getenv("API_BEARER_TOKEN")
112
 
@@ -115,68 +180,6 @@ async def validate_token(credentials: HTTPAuthorizationCredentials = Depends(sec
115
  raise HTTPException(status_code=403, detail="Invalid Authentication Token")
116
  return credentials.credentials
117
 
118
- # ==============================================================================
119
- # CONNECTION POOL MANAGER (With Keepalives)
120
- # ==============================================================================
121
- class ConnectionPoolManager:
122
- def __init__(self):
123
- self._mysql_pools = {}
124
- self._pg_pools = {}
125
- self._lock = Lock()
126
-
127
- def get_mysql_connection(self, db_url: str):
128
- with self._lock:
129
- if db_url not in self._mysql_pools:
130
- logger.info(f"Creating new MySQL pool for: {db_url}")
131
- parsed = urlparse(db_url)
132
- db_config = {
133
- "user": parsed.username,
134
- "password": parsed.password,
135
- "host": parsed.hostname,
136
- "port": parsed.port or 3306,
137
- "database": parsed.path.lstrip("/"),
138
- "connect_timeout": 5,
139
- "pool_reset_session": True # Validates connection on checkout
140
- }
141
- self._mysql_pools[db_url] = pooling.MySQLConnectionPool(
142
- pool_name=str(uuid.uuid4()),
143
- pool_size=10,
144
- **db_config
145
- )
146
- return self._mysql_pools[db_url].get_connection()
147
-
148
- def get_postgres_connection(self, db_url: str):
149
- with self._lock:
150
- if db_url not in self._pg_pools:
151
- logger.info(f"Creating new Postgres pool for: {db_url}")
152
- parsed = urlparse(db_url)
153
- qs = parse_qs(parsed.query)
154
- sslmode = qs.get('sslmode', ['require'])[0] if 'sslmode' in qs else 'prefer'
155
-
156
- db_config = {
157
- "host": parsed.hostname,
158
- "port": parsed.port or 5432,
159
- "database": parsed.path.lstrip("/"),
160
- "user": parsed.username,
161
- "password": parsed.password,
162
- "sslmode": sslmode,
163
- "connect_timeout": 5,
164
- # --- TCP Keepalives (Prevents SSL Closed Unexpectedly) ---
165
- "keepalives": 1,
166
- "keepalives_idle": 30,
167
- "keepalives_interval": 10,
168
- "keepalives_count": 5
169
- }
170
- self._pg_pools[db_url] = pg_pool.ThreadedConnectionPool(1, 10, **db_config)
171
- return self._pg_pools[db_url].getconn()
172
-
173
- def return_postgres_connection(self, db_url, conn, close=False):
174
- """Returns connection to pool. If close=True, discards it."""
175
- if db_url in self._pg_pools and conn:
176
- self._pg_pools[db_url].putconn(conn, close=close)
177
-
178
- pool_manager = ConnectionPoolManager()
179
-
180
  # ==============================================================================
181
  # REQUEST COALESCER
182
  # ==============================================================================
@@ -195,11 +198,12 @@ class RequestCoalescer:
195
  async with self._lock:
196
  if key in self._active_requests:
197
  return await self._active_requests[key]
198
- loop = asyncio.get_running_loop()
199
- future = loop.create_future()
200
  self._active_requests[key] = future
201
 
202
  try:
 
203
  result = await func(*args, **kwargs)
204
  if not future.done(): future.set_result(result)
205
  return result
@@ -214,7 +218,7 @@ class RequestCoalescer:
214
  coalescer = RequestCoalescer()
215
 
216
  # ==============================================================================
217
- # DB EXECUTION LOGIC (WITH RETRY & SELF-HEALING)
218
  # ==============================================================================
219
 
220
  def is_aggregate_query(query: str) -> bool:
@@ -245,163 +249,115 @@ def normalize_postgres_uri(uri: str) -> str:
245
  except Exception:
246
  return uri
247
 
248
- # --- SELF-HEALING MYSQL EXECUTION ---
249
- def _run_mysql_synchronously(db_url: str, sql_query: str, max_rows: int = 20, limited: bool = False) -> dict:
250
  start_time = time.time()
251
-
252
- # Retry Loop: If connection is dead, we retry once
253
- for attempt in range(2):
254
- connection = None
255
- cursor = None
256
- try:
257
- connection = pool_manager.get_mysql_connection(db_url)
258
- cursor = connection.cursor(dictionary=True)
259
-
260
- clean_query = sql_query.strip()
261
- query_lower = clean_query.lower()
262
 
263
- if not query_lower.startswith("select"):
264
- cursor.execute(clean_query)
265
- connection.commit()
266
- return {"success": True, "message": "Query executed successfully (Non-SELECT).", "executionTime": time.time() - start_time}
267
 
268
- if not limited:
269
- cursor.execute(clean_query)
270
- results = cursor.fetchall()
271
  is_aggregate = is_aggregate_query(clean_query)
272
- message = f"Raw query executed. Returned {len(results)} row(s)."
273
  is_limited_result = False
274
- else:
275
- if is_aggregate_query(clean_query):
276
- cursor.execute(clean_query)
277
- results = cursor.fetchall()
278
- is_aggregate = True
279
- message = f"Aggregate query completed. Returned {len(results)} row(s)."
280
- is_limited_result = False
281
- else:
282
- final_query = clean_query.rstrip(';').strip()
283
- if not re.search(r'\blimit\s+\d+', query_lower):
284
- final_query = f"{final_query} LIMIT {max_rows}"
285
- cursor.execute(final_query)
286
- results = cursor.fetchall()
287
- is_limited_result = (len(results) == max_rows)
288
- message = f"Showing first {max_rows} rows only." if is_limited_result else f"Returned {len(results)} rows."
289
- is_aggregate = False
290
 
291
- columns = [col[0] for col in cursor.description] if cursor.description else []
292
- return {
293
- "success": True, "results": jsonable_encoder(results), "columns": columns,
294
- "rowCount": len(results), "executionTime": time.time() - start_time,
295
- "is_aggregate": is_aggregate, "limited": is_limited_result, "message": message, "error": None
296
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
 
298
- except (mysql.connector.errors.OperationalError, mysql.connector.errors.DatabaseError) as e:
299
- # Fatal connection error
300
- if connection:
301
- try: connection.close() # Close properly so pool knows it's bad (or discards it)
302
- except: pass
303
-
304
- # If this was the first attempt, try again with a fresh connection
305
- if attempt == 0:
306
- logger.warning(f"MySQL connection lost. Retrying... Error: {e}")
307
- continue
308
-
309
- return {"success": False, "error": f"MySQL Error: {str(e)}", "executionTime": 0.0}
310
-
311
- except Exception as e:
312
- # Logic error (Syntax, etc). Do not retry.
313
- if connection: connection.close()
314
- return {"success": False, "error": str(e), "executionTime": 0.0}
315
-
316
- finally:
317
- if cursor:
318
- try: cursor.close()
319
- except: pass
320
- # Connection close handled in blocks above to handle "poisoned" logic correctly
321
 
322
- # --- SELF-HEALING POSTGRES EXECUTION ---
323
- def _run_postgres_synchronously(db_url: str, sql_query: str, max_rows: int = 20, limited: bool = False) -> dict:
324
  start_time = time.time()
325
-
326
- # Retry Loop for Dead Connections
327
- for attempt in range(2):
328
- connection = None
329
- cursor = None
330
- try:
331
- connection = pool_manager.get_postgres_connection(db_url)
332
- cursor = connection.cursor(cursor_factory=RealDictCursor)
333
-
334
  clean_query = sql_query.strip()
335
  query_lower = clean_query.lower()
336
-
337
  if not query_lower.startswith(("select", "show", "explain", "with")):
338
- cursor.execute(clean_query)
339
- connection.commit()
340
- # Return Success
341
- pool_manager.return_postgres_connection(db_url, connection)
342
  return {"success": True, "message": "Query executed successfully (Non-SELECT).", "executionTime": time.time() - start_time}
343
-
344
- # Read Logic
 
 
 
 
345
  if not limited:
346
- cursor.execute(clean_query)
347
- results = cursor.fetchall()
348
- is_aggregate = is_aggregate_query(clean_query)
349
- message = f"Raw query executed. Returned {len(results)} row(s)."
350
- is_limited_result = False
351
  else:
352
- if is_aggregate_query(clean_query):
353
- cursor.execute(clean_query)
354
- results = cursor.fetchall()
355
- is_aggregate = True
356
- message = f"Aggregate query completed. Returned {len(results)} row(s)."
357
- is_limited_result = False
358
  else:
359
  final_query = clean_query.rstrip(';').strip()
360
  if not re.search(r'\blimit\s+\d+', query_lower):
361
  final_query = f"{final_query} LIMIT {max_rows}"
362
- cursor.execute(final_query)
363
- results = cursor.fetchall()
364
- is_limited_result = (len(results) == max_rows)
365
- message = f"Showing first {max_rows} rows only." if is_limited_result else f"Returned {len(results)} rows."
366
- is_aggregate = False
367
-
368
- columns = [desc[0] for desc in cursor.description] if cursor.description else []
369
- clean_results = jsonable_encoder(results, custom_encoder={uuid.UUID: str, ObjectId: str})
370
-
371
- # Success! Return conn
372
- pool_manager.return_postgres_connection(db_url, connection)
373
- return {
374
- "success": True, "results": clean_results, "columns": columns,
375
- "rowCount": len(results), "executionTime": time.time() - start_time,
376
- "is_aggregate": is_aggregate, "limited": is_limited_result, "message": message, "error": None
377
- }
378
 
379
- except (psycopg2.InterfaceError, psycopg2.OperationalError) as e:
380
- # Bad Connection. DISCARD IT.
381
- if connection:
382
- pool_manager.return_postgres_connection(db_url, connection, close=True)
383
 
384
- if attempt == 0:
385
- logger.warning(f"Postgres connection dead ({e}). Retrying with fresh connection...")
386
- continue
387
- return {"success": False, "error": f"DB Connection Failed: {str(e)}", "executionTime": 0.0}
 
388
 
389
- except Exception as e:
390
- # Logic/SQL Error. Rollback and return to pool.
391
- if connection:
392
- try: connection.rollback()
393
- except: pass # If rollback fails, pool.putconn might fail next, but we try
394
- pool_manager.return_postgres_connection(db_url, connection, close=False)
395
-
396
- return {"success": False, "error": str(e), "executionTime": 0.0}
397
-
398
- finally:
399
- if cursor:
400
- try: cursor.close()
401
- except: pass
402
 
403
  # ==============================================================================
404
- # API ROUTES
405
  # ==============================================================================
406
 
407
  class SqlQueryRequest(BaseModel):
@@ -449,8 +405,9 @@ async def execute_mysql_endpoint(query: SqlQueryRequest, token: str = Depends(va
449
  unique_params = {
450
  "db": normalized_url, "q": query.sql_query, "l": limit_val, "lim": query.limited
451
  }
 
452
  result_dict = await coalescer.execute(
453
- "mysql", unique_params, run_in_threadpool, _run_mysql_synchronously,
454
  db_url=normalized_url, sql_query=query.sql_query, max_rows=limit_val, limited=query.limited
455
  )
456
  final_result = result_dict.copy()
@@ -468,8 +425,9 @@ async def execute_postgres_endpoint(query: PgQueryRequest, token: str = Depends(
468
  unique_params = {
469
  "db": clean_url, "q": query.sql_query, "l": limit_val, "lim": query.limited
470
  }
 
471
  result_dict = await coalescer.execute(
472
- "postgres", unique_params, run_in_threadpool, _run_postgres_synchronously,
473
  db_url=clean_url, sql_query=query.sql_query, max_rows=limit_val, limited=query.limited
474
  )
475
  final_result = result_dict.copy()
@@ -488,6 +446,7 @@ async def execute_mongo_endpoint(payload: ExecutorPayload, token: str = Depends(
488
  "uri": payload.mongo_uri, "db": payload.db_name, "col": payload.collection_name,
489
  "q": parsed_query, "lim": payload.limited, "lrows": payload.limit_rows
490
  }
 
491
  result_data = await coalescer.execute(
492
  "mongo", unique_params, run_in_threadpool, execute_mongo_operation,
493
  mongo_uri=payload.mongo_uri, db_name=payload.db_name, collection_name=payload.collection_name,
 
24
  from dotenv import load_dotenv
25
  from pydantic import BaseModel
26
 
27
+ # --- Async Database Drivers ---
28
+ import asyncpg
29
+ import asyncmy
30
+ from asyncmy.cursors import DictCursor
31
+
32
+ # --- Database Drivers (Kept for compatibility/types if needed, but unused in SQL routes) ---
33
  from bson import ObjectId
34
  import mysql.connector
 
35
  import psycopg2
 
36
  from psycopg2.extras import RealDictCursor
37
  import uvicorn
38
 
 
56
  )
57
  logger = logging.getLogger("API_Controller")
58
 
59
+ # ==============================================================================
60
+ # ASYNC CONNECTION POOL MANAGER
61
+ # ==============================================================================
62
+ class AsyncPoolManager:
63
+ def __init__(self):
64
+ self._pg_pools: Dict[str, asyncpg.Pool] = {}
65
+ self._mysql_pools: Dict[str, asyncmy.Pool] = {}
66
+ self._lock = asyncio.Lock()
67
+
68
+ async def get_pg_pool(self, db_url: str) -> asyncpg.Pool:
69
+ async with self._lock:
70
+ if db_url not in self._pg_pools:
71
+ logger.info(f"Creating new AsyncPG pool for: {db_url[:20]}...")
72
+ try:
73
+ # asyncpg handles Keepalives and SSL automatically better than psycopg2
74
+ pool = await asyncpg.create_pool(
75
+ dsn=db_url,
76
+ min_size=1,
77
+ max_size=20,
78
+ max_inactive_connection_lifetime=300,
79
+ command_timeout=60
80
+ )
81
+ self._pg_pools[db_url] = pool
82
+ except Exception as e:
83
+ logger.error(f"Failed to create PG pool: {e}")
84
+ raise e
85
+ return self._pg_pools[db_url]
86
+
87
+ async def get_mysql_pool(self, db_url: str) -> asyncmy.Pool:
88
+ async with self._lock:
89
+ if db_url not in self._mysql_pools:
90
+ logger.info(f"Creating new AsyncMy pool for: {db_url[:20]}...")
91
+ parsed = urlparse(db_url)
92
+ # Check for SSL requirement in query params
93
+ qs = parse_qs(parsed.query)
94
+ # AsyncMy often auto-negotiates SSL, but we can enforce it if needed based on qs
95
+
96
+ try:
97
+ pool = await asyncmy.create_pool(
98
+ user=parsed.username,
99
+ password=parsed.password,
100
+ host=parsed.hostname,
101
+ port=parsed.port or 3306,
102
+ db=parsed.path.lstrip("/"),
103
+ minsize=1,
104
+ maxsize=20,
105
+ autocommit=True,
106
+ pool_recycle=280, # Recycle before default 8hr timeout to prevent "Gone Away"
107
+ )
108
+ self._mysql_pools[db_url] = pool
109
+ except Exception as e:
110
+ logger.error(f"Failed to create MySQL pool: {e}")
111
+ raise e
112
+ return self._mysql_pools[db_url]
113
+
114
+ async def close_all(self):
115
+ logger.info("Closing all async database pools...")
116
+ for url, pool in self._pg_pools.items():
117
+ await pool.close()
118
+ for url, pool in self._mysql_pools.items():
119
+ pool.close()
120
+ await pool.wait_closed()
121
+
122
+ # Initialize Global Async Manager
123
+ async_pool_manager = AsyncPoolManager()
124
+
125
  def get_dynamic_thread_limit():
126
  """Calculates a safe thread limit based on available RAM."""
127
  try:
 
133
  BYTES_PER_THREAD = 8 * 1024 * 1024
134
  calculated_limit = int(safe_ram_pool / BYTES_PER_THREAD)
135
  final_limit = max(100, min(calculated_limit, 3000))
 
136
  logger.info(f"Dynamic Limit Config: {total_ram_bytes/(1024**3):.2f}GB RAM / {num_workers} Workers. Limit: {final_limit}")
137
  return final_limit
138
  except Exception as e:
 
141
 
142
  @asynccontextmanager
143
  async def lifespan(app: FastAPI):
144
+ # Startup
145
  safe_limit = get_dynamic_thread_limit()
146
  to_thread.current_default_thread_limiter().total_tokens = safe_limit
147
+ logger.info(f"Worker Process Started: Thread pool capacity set to {safe_limit}. Async Drivers Ready.")
148
  yield
149
+ # Shutdown
150
+ await async_pool_manager.close_all()
151
 
152
  app = FastAPI(title="Unified Data Executor API", lifespan=lifespan)
153
 
154
  # ==============================================================================
155
+ # MODELS & MIDDLEWARE
156
  # ==============================================================================
157
  T = TypeVar("T")
158
+ class BatchRequest(BaseModel, Generic[T]): requests: List[T]
159
+ class BatchResponse(BaseModel, Generic[T]): responses: List[T]
160
 
 
 
 
 
 
 
 
161
  CHART_DIR = "generated_charts"
162
  os.makedirs(CHART_DIR, exist_ok=True)
163
 
 
172
  allow_headers=["*"],
173
  )
174
 
 
175
  security = HTTPBearer()
176
  API_SECRET_TOKEN = os.getenv("API_BEARER_TOKEN")
177
 
 
180
  raise HTTPException(status_code=403, detail="Invalid Authentication Token")
181
  return credentials.credentials
182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  # ==============================================================================
184
  # REQUEST COALESCER
185
  # ==============================================================================
 
198
  async with self._lock:
199
  if key in self._active_requests:
200
  return await self._active_requests[key]
201
+
202
+ future = asyncio.get_running_loop().create_future()
203
  self._active_requests[key] = future
204
 
205
  try:
206
+ # Works for both async functions and run_in_threadpool
207
  result = await func(*args, **kwargs)
208
  if not future.done(): future.set_result(result)
209
  return result
 
218
  coalescer = RequestCoalescer()
219
 
220
  # ==============================================================================
221
+ # ASYNC DB LOGIC
222
  # ==============================================================================
223
 
224
  def is_aggregate_query(query: str) -> bool:
 
249
  except Exception:
250
  return uri
251
 
252
+ # --- ASYNC MYSQL EXECUTOR ---
253
+ async def _execute_async_mysql(db_url: str, sql_query: str, max_rows: int = 20, limited: bool = False) -> dict:
254
  start_time = time.time()
255
+ try:
256
+ # Get pool
257
+ pool = await async_pool_manager.get_mysql_pool(db_url)
258
+
259
+ async with pool.acquire() as conn:
260
+ async with conn.cursor(cursor=DictCursor) as cursor:
261
+ clean_query = sql_query.strip()
262
+ query_lower = clean_query.lower()
 
 
 
263
 
264
+ if not query_lower.startswith("select"):
265
+ await cursor.execute(clean_query)
266
+ return {"success": True, "message": "Query executed successfully (Non-SELECT).", "executionTime": time.time() - start_time}
 
267
 
268
+ final_query = clean_query
 
 
269
  is_aggregate = is_aggregate_query(clean_query)
 
270
  is_limited_result = False
271
+ message = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
 
273
+ if not limited:
274
+ message = f"Raw query executed."
275
+ else:
276
+ if is_aggregate:
277
+ message = f"Aggregate query completed."
278
+ else:
279
+ final_query = clean_query.rstrip(';').strip()
280
+ if not re.search(r'\blimit\s+\d+', query_lower):
281
+ final_query = f"{final_query} LIMIT {max_rows}"
282
+ is_limited_result = True
283
+ message = f"Showing first {max_rows} rows."
284
+
285
+ await cursor.execute(final_query)
286
+ results = await cursor.fetchall()
287
+
288
+ # Check actual length
289
+ if is_limited_result and len(results) < max_rows:
290
+ is_limited_result = False
291
+ message = f"Returned {len(results)} rows."
292
+ elif is_limited_result:
293
+ message = f"Showing first {len(results)} rows."
294
 
295
+ columns = [col[0] for col in cursor.description] if cursor.description else []
296
+
297
+ return {
298
+ "success": True, "results": jsonable_encoder(results), "columns": columns,
299
+ "rowCount": len(results), "executionTime": time.time() - start_time,
300
+ "is_aggregate": is_aggregate, "limited": is_limited_result, "message": message, "error": None
301
+ }
302
+ except Exception as e:
303
+ return {"success": False, "error": str(e), "executionTime": 0.0}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
 
305
+ # --- ASYNC POSTGRES EXECUTOR ---
306
+ async def _execute_async_postgres(db_url: str, sql_query: str, max_rows: int = 20, limited: bool = False) -> dict:
307
  start_time = time.time()
308
+ try:
309
+ pool = await async_pool_manager.get_pg_pool(db_url)
310
+
311
+ async with pool.acquire() as conn:
 
 
 
 
 
312
  clean_query = sql_query.strip()
313
  query_lower = clean_query.lower()
314
+
315
  if not query_lower.startswith(("select", "show", "explain", "with")):
316
+ await conn.execute(clean_query)
 
 
 
317
  return {"success": True, "message": "Query executed successfully (Non-SELECT).", "executionTime": time.time() - start_time}
318
+
319
+ final_query = clean_query
320
+ is_aggregate = is_aggregate_query(clean_query)
321
+ is_limited_result = False
322
+ message = ""
323
+
324
  if not limited:
325
+ message = f"Raw query executed."
 
 
 
 
326
  else:
327
+ if is_aggregate:
328
+ message = f"Aggregate query completed."
 
 
 
 
329
  else:
330
  final_query = clean_query.rstrip(';').strip()
331
  if not re.search(r'\blimit\s+\d+', query_lower):
332
  final_query = f"{final_query} LIMIT {max_rows}"
333
+ is_limited_result = True
334
+ message = f"Showing first {max_rows} rows."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
 
336
+ # Fetch results
337
+ records = await conn.fetch(final_query)
338
+ results = [dict(r) for r in records]
 
339
 
340
+ if is_limited_result and len(results) < max_rows:
341
+ is_limited_result = False
342
+ message = f"Returned {len(results)} rows."
343
+ elif is_limited_result:
344
+ message = f"Showing first {len(results)} rows."
345
 
346
+ columns = list(results[0].keys()) if results else []
347
+
348
+ return {
349
+ "success": True,
350
+ "results": jsonable_encoder(results, custom_encoder={uuid.UUID: str, ObjectId: str}),
351
+ "columns": columns, "rowCount": len(results),
352
+ "executionTime": time.time() - start_time,
353
+ "is_aggregate": is_aggregate, "limited": is_limited_result,
354
+ "message": message, "error": None
355
+ }
356
+ except Exception as e:
357
+ return {"success": False, "error": str(e), "executionTime": 0.0}
 
358
 
359
  # ==============================================================================
360
+ # ROUTES
361
  # ==============================================================================
362
 
363
  class SqlQueryRequest(BaseModel):
 
405
  unique_params = {
406
  "db": normalized_url, "q": query.sql_query, "l": limit_val, "lim": query.limited
407
  }
408
+ # Direct Async Call (No Threadpool)
409
  result_dict = await coalescer.execute(
410
+ "mysql", unique_params, _execute_async_mysql,
411
  db_url=normalized_url, sql_query=query.sql_query, max_rows=limit_val, limited=query.limited
412
  )
413
  final_result = result_dict.copy()
 
425
  unique_params = {
426
  "db": clean_url, "q": query.sql_query, "l": limit_val, "lim": query.limited
427
  }
428
+ # Direct Async Call (No Threadpool)
429
  result_dict = await coalescer.execute(
430
+ "postgres", unique_params, _execute_async_postgres,
431
  db_url=clean_url, sql_query=query.sql_query, max_rows=limit_val, limited=query.limited
432
  )
433
  final_result = result_dict.copy()
 
446
  "uri": payload.mongo_uri, "db": payload.db_name, "col": payload.collection_name,
447
  "q": parsed_query, "lim": payload.limited, "lrows": payload.limit_rows
448
  }
449
+ # Mongo is Sync via PyMongo -> Uses run_in_threadpool
450
  result_data = await coalescer.execute(
451
  "mongo", unique_params, run_in_threadpool, execute_mongo_operation,
452
  mongo_uri=payload.mongo_uri, db_name=payload.db_name, collection_name=payload.collection_name,
requirements.txt CHANGED
@@ -19,4 +19,6 @@ asyncpg==0.30.0
19
  aiomysql==0.2.0
20
  requests==2.32.3
21
  psutil==6.1.1
22
-
 
 
 
19
  aiomysql==0.2.0
20
  requests==2.32.3
21
  psutil==6.1.1
22
+ asyncpg==0.30.0
23
+ asyncmy==0.2.10
24
+ cryptography==43.0.3