Deadman922 commited on
Commit
b34c341
·
verified ·
1 Parent(s): 90b499d

Upload main.py

Browse files
Files changed (1) hide show
  1. server/main.py +271 -1
server/main.py CHANGED
@@ -1,7 +1,12 @@
1
  import asyncio
 
 
2
  import os
 
 
 
3
 
4
- from fastapi import FastAPI, HTTPException
5
  from fastapi.responses import HTMLResponse
6
 
7
  from .environment import SQLQualityEnvironment
@@ -23,6 +28,11 @@ app = FastAPI(
23
  # Module-level singleton — ONE instance shared across all requests
24
  _env = SQLQualityEnvironment()
25
 
 
 
 
 
 
26
  # HTTP-level request lock to serialize concurrent requests
27
  _request_lock = asyncio.Lock()
28
 
@@ -170,6 +180,266 @@ async def state() -> SQLState:
170
  raise HTTPException(status_code=422, detail=str(e))
171
 
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  def run_server() -> None:
174
  """
175
  Entry point for 'uv run server' command.
 
1
  import asyncio
2
+ import csv
3
+ import io
4
  import os
5
+ import re
6
+ import sqlite3
7
+ from typing import Any
8
 
9
+ from fastapi import FastAPI, HTTPException, UploadFile, File
10
  from fastapi.responses import HTMLResponse
11
 
12
  from .environment import SQLQualityEnvironment
 
28
  # Module-level singleton — ONE instance shared across all requests
29
  _env = SQLQualityEnvironment()
30
 
31
+ # Module-level custom database connection (separate from RL environment)
32
+ _custom_db_conn: sqlite3.Connection | None = None
33
+ _custom_db_schema: list[dict] = []
34
+ _custom_db_filename: str = ""
35
+
36
  # HTTP-level request lock to serialize concurrent requests
37
  _request_lock = asyncio.Lock()
38
 
 
180
  raise HTTPException(status_code=422, detail=str(e))
181
 
182
 
183
+ # ─── CUSTOM DATABASE ENDPOINTS ─────────────────────────────────────────
184
+
185
+ def infer_sqlite_type(values: list[str]) -> str:
186
+ """Infer SQLite column type from a list of string values."""
187
+ non_empty = [v for v in values if v and v.strip()]
188
+ if not non_empty:
189
+ return 'TEXT'
190
+ try:
191
+ for v in non_empty:
192
+ int(v)
193
+ return 'INTEGER'
194
+ except ValueError:
195
+ pass
196
+ try:
197
+ for v in non_empty:
198
+ float(v)
199
+ return 'REAL'
200
+ except ValueError:
201
+ pass
202
+ return 'TEXT'
203
+
204
+
205
+ def get_db_schema(conn: sqlite3.Connection) -> list[dict]:
206
+ """Extract schema information from a SQLite connection."""
207
+ schema = []
208
+ cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
209
+ tables = cursor.fetchall()
210
+
211
+ for (table_name,) in tables:
212
+ # Get row count
213
+ count_cursor = conn.execute(f'SELECT COUNT(*) FROM "{table_name}"')
214
+ row_count = count_cursor.fetchone()[0]
215
+
216
+ # Get columns
217
+ pragma_cursor = conn.execute(f'PRAGMA table_info("{table_name}")')
218
+ columns = []
219
+ for row in pragma_cursor.fetchall():
220
+ col_name = row[1]
221
+ col_type = row[2]
222
+ columns.append(f"{col_name} ({col_type})")
223
+
224
+ schema.append({
225
+ "table_name": table_name,
226
+ "row_count": row_count,
227
+ "columns": columns
228
+ })
229
+
230
+ return schema
231
+
232
+
233
+ @app.post("/upload-db")
234
+ async def upload_db(file: UploadFile = File(...)) -> dict:
235
+ """Upload a .sqlite, .db, or .csv file for custom exploration.
236
+
237
+ Args:
238
+ file: The uploaded file (.sqlite, .db, or .csv)
239
+
240
+ Returns:
241
+ dict: Status and schema information for the uploaded database
242
+
243
+ Raises:
244
+ HTTPException 400: If file extension is invalid
245
+ HTTPException 413: If file is too large (>15MB)
246
+ HTTPException 500: If processing fails
247
+ """
248
+ global _custom_db_conn, _custom_db_schema, _custom_db_filename
249
+
250
+ # Validate file extension
251
+ filename = file.filename or ""
252
+ ext = os.path.splitext(filename)[1].lower()
253
+ valid_extensions = {'.sqlite', '.db', '.csv'}
254
+
255
+ if ext not in valid_extensions:
256
+ raise HTTPException(
257
+ status_code=400,
258
+ detail=f"Invalid file extension: {ext}. Must be one of: {valid_extensions}"
259
+ )
260
+
261
+ # Read file content
262
+ content = await file.read()
263
+
264
+ # Validate file size (15MB limit)
265
+ max_size = 15 * 1024 * 1024
266
+ if len(content) > max_size:
267
+ raise HTTPException(
268
+ status_code=413,
269
+ detail=f"File too large: {len(content)} bytes. Maximum is {max_size} bytes (15MB)"
270
+ )
271
+
272
+ try:
273
+ # Close existing connection if any
274
+ if _custom_db_conn is not None:
275
+ try:
276
+ _custom_db_conn.close()
277
+ except Exception:
278
+ pass
279
+ _custom_db_conn = None
280
+
281
+ if ext == '.csv':
282
+ # Parse CSV and create in-memory SQLite database
283
+ conn = sqlite3.connect(":memory:", check_same_thread=False)
284
+
285
+ # Read CSV content
286
+ csv_text = content.decode('utf-8')
287
+ csv_file = io.StringIO(csv_text)
288
+ reader = csv.DictReader(csv_file)
289
+
290
+ if not reader.fieldnames:
291
+ raise HTTPException(status_code=400, detail="CSV file has no headers")
292
+
293
+ # Infer types from first 100 rows
294
+ rows = list(reader)
295
+ if not rows:
296
+ raise HTTPException(status_code=400, detail="CSV file is empty")
297
+
298
+ # Create table
299
+ table_name = os.path.splitext(os.path.basename(filename))[0]
300
+ # Sanitize table name
301
+ table_name = re.sub(r'[^a-zA-Z0-9_]', '_', table_name)
302
+ if not table_name[0].isalpha() and table_name[0] != '_':
303
+ table_name = '_' + table_name
304
+
305
+ columns = []
306
+ for col_name in reader.fieldnames:
307
+ # Sanitize column name
308
+ safe_col = re.sub(r'[^a-zA-Z0-9_]', '_', col_name)
309
+ if not safe_col[0].isalpha() and safe_col[0] != '_':
310
+ safe_col = '_' + safe_col
311
+
312
+ values = [row.get(col_name, '') for row in rows[:100]]
313
+ col_type = infer_sqlite_type(values)
314
+ columns.append(f'"{safe_col}" {col_type}')
315
+
316
+ create_sql = f'CREATE TABLE "{table_name}" ({', '.join(columns)})'
317
+ conn.execute(create_sql)
318
+
319
+ # Insert rows
320
+ placeholders = ', '.join(['?' for _ in reader.fieldnames])
321
+ insert_sql = f'INSERT INTO "{table_name}" VALUES ({placeholders})'
322
+
323
+ for row in rows:
324
+ values = [row.get(col_name, None) for col_name in reader.fieldnames]
325
+ conn.execute(insert_sql, values)
326
+
327
+ conn.commit()
328
+ _custom_db_conn = conn
329
+
330
+ else:
331
+ # .sqlite or .db file - write to temp and open
332
+ import tempfile
333
+ tmp = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
334
+ tmp.write(content)
335
+ tmp.close()
336
+
337
+ conn = sqlite3.connect(tmp.name, check_same_thread=False, uri=True)
338
+ conn.execute("PRAGMA query_only = ON") # Read-only mode for safety
339
+ _custom_db_conn = conn
340
+
341
+ # Get schema
342
+ _custom_db_schema = get_db_schema(_custom_db_conn)
343
+ _custom_db_filename = filename
344
+
345
+ return {
346
+ "status": "ok",
347
+ "filename": filename,
348
+ "tables": _custom_db_schema
349
+ }
350
+
351
+ except HTTPException:
352
+ raise
353
+ except Exception as e:
354
+ raise HTTPException(status_code=500, detail=f"Failed to process file: {str(e)}")
355
+
356
+
357
+ @app.post("/query-custom")
358
+ async def query_custom(body: dict) -> dict:
359
+ """Execute a SELECT query against the uploaded custom database.
360
+
361
+ Args:
362
+ body: JSON body with {"sql": "SELECT ..."}
363
+
364
+ Returns:
365
+ dict: Query results with rows, columns, and row_count
366
+
367
+ Raises:
368
+ HTTPException 422: If no database uploaded or SQL is invalid
369
+ """
370
+ global _custom_db_conn
371
+
372
+ if _custom_db_conn is None:
373
+ raise HTTPException(
374
+ status_code=422,
375
+ detail="No database uploaded yet. Use /upload-db first."
376
+ )
377
+
378
+ sql = body.get("sql", "").strip()
379
+ if not sql:
380
+ raise HTTPException(status_code=422, detail="No SQL provided")
381
+
382
+ # Validate SQL starts with SELECT (same rule as QUERY action)
383
+ sql_upper = sql.upper()
384
+ if not (sql_upper.startswith("SELECT") or sql_upper.startswith("WITH") or
385
+ sql_upper.startswith("EXPLAIN") or sql_upper.startswith("PRAGMA")):
386
+ return {
387
+ "error": "QUERY action is read-only. This action only accepts SELECT, WITH, EXPLAIN, or PRAGMA.",
388
+ "message": "SQL Error — check your query. Only SELECT queries are allowed in custom mode."
389
+ }
390
+
391
+ try:
392
+ cursor = _custom_db_conn.execute(sql)
393
+
394
+ if cursor.description:
395
+ columns = [d[0] for d in cursor.description]
396
+ rows = cursor.fetchall()
397
+
398
+ # Convert rows to dicts
399
+ row_dicts = []
400
+ for row in rows:
401
+ row_dict = {}
402
+ for i, col in enumerate(columns):
403
+ row_dict[col] = row[i]
404
+ row_dicts.append(row_dict)
405
+
406
+ return {
407
+ "rows": row_dicts,
408
+ "columns": columns,
409
+ "row_count": len(row_dicts)
410
+ }
411
+ else:
412
+ return {
413
+ "rows": [],
414
+ "columns": [],
415
+ "row_count": 0,
416
+ "message": "Query executed successfully but returned no rows"
417
+ }
418
+
419
+ except sqlite3.Error as e:
420
+ return {
421
+ "error": str(e),
422
+ "message": "SQL Error — check your query. Check table and column names in the schema panel."
423
+ }
424
+
425
+
426
+ @app.get("/custom-db-status")
427
+ async def custom_db_status() -> dict:
428
+ """Check if a custom database is loaded and return its schema.
429
+
430
+ Returns:
431
+ dict: {loaded: true/false, tables: [...]}
432
+ """
433
+ if _custom_db_conn is None:
434
+ return {"loaded": False}
435
+
436
+ return {
437
+ "loaded": True,
438
+ "filename": _custom_db_filename,
439
+ "tables": _custom_db_schema
440
+ }
441
+
442
+
443
  def run_server() -> None:
444
  """
445
  Entry point for 'uv run server' command.