File size: 7,755 Bytes
f959360 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 |
import os
import duckdb
from fastapi import FastAPI, HTTPException, Body
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, Field
from pathlib import Path
import logging
import time # Import time for potential startup delays
import asyncio
# --- Configuration ---
DB_DIR = Path("data")
DB_FILENAME = "mydatabase.db"
DB_FILE = DB_DIR / DB_FILENAME
UI_EXPECTED_PORT = 8080 # Default port DuckDB UI often tries first
# Ensure the data directory exists
DB_DIR.mkdir(parents=True, exist_ok=True)
# --- Logging Setup ---
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# --- FastAPI App ---
app = FastAPI(
title="DuckDB API & UI Host",
description="Interact with DuckDB via API (/query, /download) and access the official DuckDB Web UI.",
version="1.0.0"
)
# --- Pydantic Models ---
class QueryRequest(BaseModel):
sql: str = Field(..., description="The SQL query to execute against DuckDB.")
class QueryResponse(BaseModel):
columns: list[str] | None = None
rows: list[dict] | None = None
message: str | None = None
error: str | None = None
# --- Helper Function ---
def execute_duckdb_query(sql_query: str, db_path: str = str(DB_FILE)):
"""Connects to DuckDB, executes a query, and returns results or error."""
con = None
try:
logger.info(f"Connecting to database: {db_path}")
con = duckdb.connect(database=db_path, read_only=False)
logger.info(f"Executing SQL: {sql_query[:200]}{'...' if len(sql_query) > 200 else ''}")
con.begin()
result_relation = con.execute(sql_query)
response_data = {"columns": None, "rows": None, "message": None, "error": None}
if result_relation.description:
columns = [desc[0] for desc in result_relation.description]
rows_raw = result_relation.fetchall()
rows_dict = [dict(zip(columns, row)) for row in rows_raw]
response_data["columns"] = columns
response_data["rows"] = rows_dict
response_data["message"] = f"Query executed successfully. Fetched {len(rows_dict)} row(s)."
logger.info(f"Query successful, returned {len(rows_dict)} rows.")
else:
response_data["message"] = "Query executed successfully (no data returned)."
logger.info("Query successful (no data returned).")
con.commit()
return response_data
except duckdb.Error as e:
logger.error(f"DuckDB Error: {e}")
if con: con.rollback()
return {"columns": None, "rows": None, "message": None, "error": str(e)}
except Exception as e:
logger.error(f"General Error: {e}")
if con: con.rollback()
return {"columns": None, "rows": None, "message": None, "error": f"An unexpected error occurred: {e}"}
finally:
if con:
con.close()
logger.info("Database connection closed.")
# --- FastAPI Startup Event ---
@app.on_event("startup")
async def startup_event():
logger.info("Application startup: Initializing DuckDB UI...")
con = None
try:
# Connect to the main DB file to execute initialization commands
# Use a temporary in-memory DB for UI start if main DB doesn't exist yet?
# No, start_ui seems to need the target DB. Ensure DB file path exists.
if not DB_FILE.parent.exists():
DB_FILE.parent.mkdir(parents=True, exist_ok=True)
# It's crucial the UI extension can write its state.
# By default it uses ~/.duckdb/ which will be /root/.duckdb in the container.
# Ensure this is writable or mount a volume there.
logger.info(f"Attempting to connect to {DB_FILE} for UI setup.")
con = duckdb.connect(database=str(DB_FILE), read_only=False)
logger.info("Installing and loading 'ui' extension...")
con.execute("INSTALL ui;")
con.execute("LOAD ui;")
logger.info("Calling start_ui()... This will start a separate web server.")
# CALL start_ui() starts the server in the background (usually)
# It might print the URL/port it's using to stderr/stdout of the main process
con.execute("CALL start_ui();")
# Give the UI server a moment to start up. This is a guess.
# A more robust solution might involve checking if the port is listening.
await asyncio.sleep(2)
logger.info(f"DuckDB UI server startup initiated. It usually listens on port {UI_EXPECTED_PORT}.")
logger.info("Check container logs for the exact URL if it differs.")
logger.info("API server (FastAPI/Uvicorn) is running on port 8000.")
except duckdb.Error as e:
logger.error(f"CRITICAL: Failed to install/load/start DuckDB UI extension: {e}")
logger.error("The DuckDB UI will likely not be available.")
except Exception as e:
logger.error(f"CRITICAL: An unexpected error occurred during UI startup: {e}")
logger.error("The DuckDB UI will likely not be available.")
finally:
if con:
con.close()
logger.info("UI setup connection closed.")
# --- API Endpoints ---
@app.get("/", summary="Root Endpoint / Info", tags=["General"])
async def read_root():
"""Provides links to the API docs and the DuckDB UI."""
# Assumes UI is running on localhost from the container's perspective
# User needs to map the port correctly
return JSONResponse({
"message": "DuckDB API and UI Host",
"api_details": {
"docs": "/docs",
"query_endpoint": "/query (POST)",
"download_endpoint": "/download (GET)"
},
"duckdb_ui": {
"message": f"Access the official DuckDB Web UI. It should be running on port {UI_EXPECTED_PORT} inside the container.",
"typical_access_url": f"http://localhost:{UI_EXPECTED_PORT}",
"notes": f"Ensure you have mapped port {UI_EXPECTED_PORT} from the container when running `docker run` (e.g., -p {UI_EXPECTED_PORT}:{UI_EXPECTED_PORT})."
},
"database_file_container_path": str(DB_FILE)
})
@app.post("/query", response_model=QueryResponse, summary="Execute SQL Query", tags=["Database API"])
async def execute_query_endpoint(query_request: QueryRequest):
"""
Executes a given SQL query against the DuckDB database via the API.
Handles SELECT, INSERT, UPDATE, DELETE, CREATE TABLE, etc.
"""
result = execute_duckdb_query(query_request.sql)
if result["error"]:
raise HTTPException(status_code=400, detail=result["error"])
return JSONResponse(content=result)
@app.get("/download", summary="Download Database File", tags=["Database API"])
async def download_database_file():
"""
Allows downloading the current DuckDB database file via the API.
"""
if not DB_FILE.is_file():
logger.error(f"Download request failed: Database file not found at {DB_FILE}")
raise HTTPException(status_code=404, detail="Database file not found.")
logger.info(f"Serving database file for download: {DB_FILE}")
return FileResponse(
path=str(DB_FILE),
filename=DB_FILENAME,
media_type='application/octet-stream'
)
# Need asyncio for sleep in startup
# import asyncio
# --- Run with Uvicorn (for local testing - doesn't handle UI startup well here) ---
# if __name__ == "__main__":
# # Note: Running directly with python main.py won't trigger the startup
# # event correctly in the same way uvicorn command does.
# # Use `uvicorn main:app --reload --port 8000` for local dev testing.
# print("Run using: uvicorn main:app --host 0.0.0.0 --port 8000") |