UI
Browse files- index.html +161 -141
- main.py +65 -138
index.html
CHANGED
|
@@ -5,6 +5,7 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
<title>DuckDB Explorer</title>
|
| 7 |
<style>
|
|
|
|
| 8 |
body {
|
| 9 |
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
| 10 |
margin: 0;
|
|
@@ -17,35 +18,26 @@
|
|
| 17 |
}
|
| 18 |
|
| 19 |
header {
|
| 20 |
-
background-color: #4CAF50;
|
| 21 |
color: white;
|
| 22 |
-
padding:
|
| 23 |
display: flex;
|
| 24 |
align-items: center;
|
| 25 |
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
|
|
|
|
|
| 26 |
}
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
background-color: #367c39;
|
| 39 |
-
color: white;
|
| 40 |
-
border: none;
|
| 41 |
-
border-radius: 4px;
|
| 42 |
-
cursor: pointer;
|
| 43 |
-
transition: background-color 0.2s;
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
header button:hover {
|
| 47 |
-
background-color: #2a622d;
|
| 48 |
-
}
|
| 49 |
|
| 50 |
.container {
|
| 51 |
display: flex;
|
|
@@ -54,7 +46,7 @@
|
|
| 54 |
}
|
| 55 |
|
| 56 |
#sidebar {
|
| 57 |
-
width:
|
| 58 |
background-color: #e9ecef;
|
| 59 |
padding: 15px;
|
| 60 |
overflow-y: auto;
|
|
@@ -63,7 +55,10 @@
|
|
| 63 |
|
| 64 |
#sidebar h3 {
|
| 65 |
margin-top: 0;
|
|
|
|
| 66 |
color: #495057;
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
|
| 69 |
#tableList {
|
|
@@ -73,16 +68,24 @@
|
|
| 73 |
}
|
| 74 |
|
| 75 |
#tableList li {
|
| 76 |
-
padding: 8px
|
| 77 |
cursor: pointer;
|
| 78 |
border-radius: 4px;
|
| 79 |
margin-bottom: 5px;
|
| 80 |
-
transition: background-color 0.2s;
|
|
|
|
|
|
|
| 81 |
}
|
| 82 |
|
| 83 |
-
#tableList li:hover
|
| 84 |
background-color: #d4dadf;
|
| 85 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
#mainContent {
|
| 88 |
flex: 1;
|
|
@@ -97,103 +100,128 @@
|
|
| 97 |
display: flex;
|
| 98 |
flex-direction: column;
|
| 99 |
overflow: hidden; /* Child takes scroll */
|
|
|
|
| 100 |
}
|
| 101 |
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
}
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
|
| 116 |
#queryArea {
|
| 117 |
-
margin-top: auto; /* Push query area to bottom if space */
|
| 118 |
padding-top: 20px;
|
| 119 |
border-top: 1px solid #dee2e6;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
#queryArea textarea {
|
| 123 |
width: 100%;
|
| 124 |
min-height: 80px;
|
| 125 |
padding: 10px;
|
| 126 |
-
border: 1px solid #
|
| 127 |
border-radius: 4px;
|
| 128 |
box-sizing: border-box;
|
| 129 |
font-family: monospace;
|
| 130 |
margin-bottom: 10px;
|
|
|
|
| 131 |
}
|
| 132 |
|
| 133 |
#queryArea button {
|
| 134 |
padding: 10px 20px;
|
| 135 |
-
background-color: #
|
| 136 |
color: white;
|
| 137 |
border: none;
|
| 138 |
border-radius: 4px;
|
| 139 |
cursor: pointer;
|
| 140 |
transition: background-color 0.2s;
|
|
|
|
| 141 |
}
|
| 142 |
#queryArea button:hover {
|
| 143 |
-
background-color: #
|
| 144 |
}
|
| 145 |
|
| 146 |
table {
|
| 147 |
width: 100%;
|
| 148 |
border-collapse: collapse;
|
| 149 |
-
margin-top:
|
|
|
|
| 150 |
}
|
| 151 |
|
| 152 |
th, td {
|
| 153 |
-
border: 1px solid #
|
| 154 |
-
padding:
|
| 155 |
text-align: left;
|
| 156 |
white-space: nowrap;
|
|
|
|
|
|
|
|
|
|
| 157 |
}
|
| 158 |
|
| 159 |
th {
|
| 160 |
-
background-color: #
|
| 161 |
-
font-weight:
|
|
|
|
|
|
|
|
|
|
| 162 |
}
|
| 163 |
|
| 164 |
tr:nth-child(even) {
|
| 165 |
-
background-color: #
|
| 166 |
}
|
| 167 |
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
|
|
|
| 174 |
|
| 175 |
#statusMessage.success {
|
| 176 |
-
background-color: #
|
| 177 |
-
color: #
|
| 178 |
-
border: 1px solid #
|
| 179 |
}
|
| 180 |
|
| 181 |
#statusMessage.error {
|
| 182 |
background-color: #f8d7da;
|
| 183 |
-
color: #
|
| 184 |
-
border: 1px solid #
|
| 185 |
}
|
| 186 |
-
|
| 187 |
-
border: 4px solid #f3f3f3; /* Light grey */
|
| 188 |
-
border-top: 4px solid #3498db; /* Blue */
|
| 189 |
-
border-radius: 50%;
|
| 190 |
-
width: 20px;
|
| 191 |
-
height: 20px;
|
| 192 |
-
animation: spin 1s linear infinite;
|
| 193 |
-
display: none; /* Hidden by default */
|
| 194 |
-
margin-left: 10px;
|
| 195 |
-
}
|
| 196 |
-
|
| 197 |
@keyframes spin {
|
| 198 |
0% { transform: rotate(0deg); }
|
| 199 |
100% { transform: rotate(360deg); }
|
|
@@ -203,17 +231,15 @@
|
|
| 203 |
<body>
|
| 204 |
|
| 205 |
<header>
|
| 206 |
-
<
|
| 207 |
-
|
| 208 |
-
<button id="connectButton">Connect</button>
|
| 209 |
-
<div class="loader" id="loadingIndicator"></div>
|
| 210 |
</header>
|
| 211 |
|
| 212 |
<div class="container">
|
| 213 |
<aside id="sidebar">
|
| 214 |
<h3>Tables</h3>
|
| 215 |
<ul id="tableList">
|
| 216 |
-
|
| 217 |
</ul>
|
| 218 |
</aside>
|
| 219 |
|
|
@@ -221,27 +247,27 @@
|
|
| 221 |
<div class="content-area">
|
| 222 |
<div id="schemaDisplay">
|
| 223 |
<h4>Schema</h4>
|
| 224 |
-
<p>Select a table from the list
|
| 225 |
<table id="schemaTable"></table>
|
| 226 |
</div>
|
| 227 |
<div id="dataDisplayContainer">
|
| 228 |
<h4>Data <span id="tableDataHeader"></span></h4>
|
| 229 |
-
<p>Select a table from the list
|
| 230 |
-
<div id="dataDisplay"
|
| 231 |
<table id="dataTable"></table>
|
| 232 |
</div>
|
| 233 |
</div>
|
| 234 |
<div id="queryResultContainer" style="display: none;">
|
| 235 |
<h4>Query Result</h4>
|
| 236 |
-
<div id="queryResultDisplay"
|
| 237 |
<table id="queryResultTable"></table>
|
| 238 |
</div>
|
| 239 |
</div>
|
| 240 |
</div>
|
| 241 |
|
| 242 |
<div id="queryArea">
|
| 243 |
-
<h4>Custom SQL Query (SELECT only)</h4>
|
| 244 |
-
<textarea id="sqlInput" placeholder="Enter your SELECT query here..."></textarea>
|
| 245 |
<button id="runSqlButton">Run SQL</button>
|
| 246 |
</div>
|
| 247 |
|
|
@@ -250,8 +276,7 @@
|
|
| 250 |
</div>
|
| 251 |
|
| 252 |
<script>
|
| 253 |
-
|
| 254 |
-
const connectButton = document.getElementById('connectButton');
|
| 255 |
const tableList = document.getElementById('tableList');
|
| 256 |
const schemaDisplay = document.getElementById('schemaDisplay');
|
| 257 |
const schemaTable = document.getElementById('schemaTable');
|
|
@@ -267,12 +292,12 @@
|
|
| 267 |
const statusMessage = document.getElementById('statusMessage');
|
| 268 |
const loadingIndicator = document.getElementById('loadingIndicator');
|
| 269 |
|
| 270 |
-
|
|
|
|
| 271 |
let currentTables = [];
|
| 272 |
let selectedTable = null;
|
| 273 |
|
| 274 |
-
// --- Utility Functions ---
|
| 275 |
-
|
| 276 |
function showLoader(show) {
|
| 277 |
loadingIndicator.style.display = show ? 'inline-block' : 'none';
|
| 278 |
}
|
|
@@ -281,7 +306,6 @@
|
|
| 281 |
statusMessage.textContent = message;
|
| 282 |
statusMessage.className = isError ? 'error' : 'success';
|
| 283 |
statusMessage.style.display = 'block';
|
| 284 |
-
// Automatically hide after a few seconds
|
| 285 |
setTimeout(() => { statusMessage.style.display = 'none'; }, 5000);
|
| 286 |
}
|
| 287 |
|
|
@@ -293,7 +317,7 @@
|
|
| 293 |
async function fetchAPI(endpoint, options = {}) {
|
| 294 |
showLoader(true);
|
| 295 |
clearStatus();
|
| 296 |
-
const url = `${API_BASE_URL}${endpoint}`;
|
| 297 |
try {
|
| 298 |
const response = await fetch(url, options);
|
| 299 |
if (!response.ok) {
|
|
@@ -301,31 +325,29 @@
|
|
| 301 |
try {
|
| 302 |
const errorJson = await response.json();
|
| 303 |
errorDetail += ` - ${errorJson.detail || JSON.stringify(errorJson)}`;
|
| 304 |
-
} catch (e) { /* Ignore
|
| 305 |
throw new Error(errorDetail);
|
| 306 |
}
|
| 307 |
-
// Handle empty responses for non-JSON endpoints if necessary
|
| 308 |
if (response.headers.get("content-type")?.includes("application/json")) {
|
| 309 |
return await response.json();
|
| 310 |
}
|
| 311 |
-
|
|
|
|
| 312 |
} catch (error) {
|
| 313 |
console.error('API Fetch Error:', error);
|
| 314 |
showStatus(`Error: ${error.message}`, true);
|
| 315 |
-
throw error;
|
| 316 |
} finally {
|
| 317 |
showLoader(false);
|
| 318 |
}
|
| 319 |
}
|
| 320 |
|
| 321 |
function renderTable(data, tableElement) {
|
| 322 |
-
tableElement.innerHTML = '';
|
| 323 |
-
|
| 324 |
if (!data || data.length === 0) {
|
| 325 |
tableElement.innerHTML = '<tbody><tr><td>No data available.</td></tr></tbody>';
|
| 326 |
return;
|
| 327 |
}
|
| 328 |
-
|
| 329 |
const headers = Object.keys(data[0]);
|
| 330 |
const thead = tableElement.createTHead();
|
| 331 |
const headerRow = thead.insertRow();
|
|
@@ -334,29 +356,26 @@
|
|
| 334 |
th.textContent = headerText;
|
| 335 |
headerRow.appendChild(th);
|
| 336 |
});
|
| 337 |
-
|
| 338 |
const tbody = tableElement.createTBody();
|
| 339 |
data.forEach(rowData => {
|
| 340 |
const row = tbody.insertRow();
|
| 341 |
headers.forEach(header => {
|
| 342 |
const cell = row.insertCell();
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
|
|
|
| 346 |
});
|
| 347 |
}
|
| 348 |
|
| 349 |
function renderSchema(schemaData) {
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
if (!schemaData || !schemaData.columns || schemaData.columns.length === 0) {
|
| 354 |
schemaDisplay.innerHTML = '<h4>Schema</h4><p>No schema information available.</p>';
|
| 355 |
return;
|
| 356 |
}
|
| 357 |
-
|
| 358 |
-
schemaDisplay.innerHTML = '<h4>Schema</h4>'; // Reset header
|
| 359 |
-
|
| 360 |
const thead = tableElement.createTHead();
|
| 361 |
const headerRow = thead.insertRow();
|
| 362 |
['Name', 'Type'].forEach(headerText => {
|
|
@@ -364,7 +383,6 @@
|
|
| 364 |
th.textContent = headerText;
|
| 365 |
headerRow.appendChild(th);
|
| 366 |
});
|
| 367 |
-
|
| 368 |
const tbody = tableElement.createTBody();
|
| 369 |
schemaData.columns.forEach(column => {
|
| 370 |
const row = tbody.insertRow();
|
|
@@ -373,31 +391,33 @@
|
|
| 373 |
});
|
| 374 |
}
|
| 375 |
|
| 376 |
-
|
| 377 |
-
// --- Event Handlers ---
|
| 378 |
|
| 379 |
async function loadTables() {
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
|
|
|
| 385 |
try {
|
| 386 |
-
// Optional: Ping root or health endpoint first
|
| 387 |
-
// await fetchAPI('/');
|
| 388 |
currentTables = await fetchAPI('/tables');
|
| 389 |
displayTables(currentTables);
|
| 390 |
-
showStatus("
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
|
|
|
|
|
|
|
|
|
| 396 |
} catch (error) {
|
| 397 |
tableList.innerHTML = '<li>Error loading tables.</li>';
|
| 398 |
}
|
| 399 |
}
|
| 400 |
|
|
|
|
| 401 |
function displayTables(tables) {
|
| 402 |
tableList.innerHTML = ''; // Clear list
|
| 403 |
if (tables.length === 0) {
|
|
@@ -414,49 +434,51 @@
|
|
| 414 |
}
|
| 415 |
|
| 416 |
async function handleTableSelection(listItem) {
|
| 417 |
-
// Remove active class from previously selected
|
| 418 |
const currentActive = tableList.querySelector('.active');
|
| 419 |
if (currentActive) {
|
| 420 |
currentActive.classList.remove('active');
|
| 421 |
}
|
| 422 |
-
// Add active class to newly selected
|
| 423 |
listItem.classList.add('active');
|
| 424 |
|
| 425 |
selectedTable = listItem.dataset.tableName;
|
| 426 |
if (!selectedTable) return;
|
| 427 |
|
| 428 |
-
queryResultContainer.style.display = 'none';
|
| 429 |
-
dataDisplayContainer.style.display = 'flex'; //
|
|
|
|
| 430 |
|
| 431 |
tableDataHeader.textContent = `for table "${selectedTable}"`;
|
|
|
|
| 432 |
schemaTable.innerHTML = '<tbody><tr><td>Loading schema...</td></tr></tbody>';
|
| 433 |
dataTable.innerHTML = '<tbody><tr><td>Loading data...</td></tr></tbody>';
|
| 434 |
|
| 435 |
try {
|
| 436 |
-
|
|
|
|
| 437 |
fetchAPI(`/tables/${selectedTable}/schema`),
|
| 438 |
-
fetchAPI(`/tables/${selectedTable}?limit=100`) //
|
| 439 |
]);
|
| 440 |
-
renderSchema(
|
| 441 |
-
renderTable(
|
| 442 |
} catch (error) {
|
| 443 |
-
|
|
|
|
| 444 |
dataTable.innerHTML = '<tbody><tr><td>Error loading data.</td></tr></tbody>';
|
| 445 |
}
|
| 446 |
}
|
| 447 |
|
|
|
|
|
|
|
| 448 |
async function runCustomQuery() {
|
| 449 |
const sql = sqlInput.value.trim();
|
| 450 |
if (!sql) {
|
| 451 |
showStatus("SQL query cannot be empty.", true);
|
| 452 |
return;
|
| 453 |
}
|
| 454 |
-
|
| 455 |
-
showStatus("Connect to the API first (enter URL and press Connect).", true);
|
| 456 |
-
return;
|
| 457 |
-
}
|
| 458 |
|
| 459 |
dataDisplayContainer.style.display = 'none'; // Hide table data
|
|
|
|
| 460 |
queryResultContainer.style.display = 'block'; // Show query results area
|
| 461 |
queryResultTable.innerHTML = '<tbody><tr><td>Running query...</td></tr></tbody>';
|
| 462 |
|
|
@@ -472,19 +494,17 @@
|
|
| 472 |
renderTable(resultData, queryResultTable);
|
| 473 |
showStatus("Query executed successfully.", false);
|
| 474 |
} catch (error) {
|
| 475 |
-
|
| 476 |
-
//
|
| 477 |
}
|
| 478 |
}
|
| 479 |
|
| 480 |
// --- Initial Setup ---
|
| 481 |
-
connectButton
|
| 482 |
runSqlButton.onclick = runCustomQuery;
|
| 483 |
|
| 484 |
-
//
|
| 485 |
-
|
| 486 |
-
// loadTables();
|
| 487 |
-
// }
|
| 488 |
|
| 489 |
</script>
|
| 490 |
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
<title>DuckDB Explorer</title>
|
| 7 |
<style>
|
| 8 |
+
/* --- Keep the existing CSS from the previous answer --- */
|
| 9 |
body {
|
| 10 |
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
| 11 |
margin: 0;
|
|
|
|
| 18 |
}
|
| 19 |
|
| 20 |
header {
|
| 21 |
+
background-color: #4CAF50; /* Changed color slightly */
|
| 22 |
color: white;
|
| 23 |
+
padding: 15px 20px; /* Slightly more padding */
|
| 24 |
display: flex;
|
| 25 |
align-items: center;
|
| 26 |
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
| 27 |
+
font-size: 1.2em; /* Bigger title */
|
| 28 |
+
font-weight: bold;
|
| 29 |
}
|
| 30 |
+
/* Style for the loader inside the header */
|
| 31 |
+
header .loader {
|
| 32 |
+
border: 3px solid #f3f3f3; /* Light grey */
|
| 33 |
+
border-top: 3px solid #fff; /* White */
|
| 34 |
+
border-radius: 50%;
|
| 35 |
+
width: 18px;
|
| 36 |
+
height: 18px;
|
| 37 |
+
animation: spin 1s linear infinite;
|
| 38 |
+
display: none; /* Hidden by default */
|
| 39 |
+
margin-left: 15px; /* Space from title */
|
| 40 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
.container {
|
| 43 |
display: flex;
|
|
|
|
| 46 |
}
|
| 47 |
|
| 48 |
#sidebar {
|
| 49 |
+
width: 220px; /* Slightly wider */
|
| 50 |
background-color: #e9ecef;
|
| 51 |
padding: 15px;
|
| 52 |
overflow-y: auto;
|
|
|
|
| 55 |
|
| 56 |
#sidebar h3 {
|
| 57 |
margin-top: 0;
|
| 58 |
+
margin-bottom: 15px; /* More space */
|
| 59 |
color: #495057;
|
| 60 |
+
border-bottom: 1px solid #ced4da;
|
| 61 |
+
padding-bottom: 10px;
|
| 62 |
}
|
| 63 |
|
| 64 |
#tableList {
|
|
|
|
| 68 |
}
|
| 69 |
|
| 70 |
#tableList li {
|
| 71 |
+
padding: 8px 10px; /* More padding */
|
| 72 |
cursor: pointer;
|
| 73 |
border-radius: 4px;
|
| 74 |
margin-bottom: 5px;
|
| 75 |
+
transition: background-color 0.2s, color 0.2s; /* Add color transition */
|
| 76 |
+
font-size: 0.95em;
|
| 77 |
+
color: #343a40;
|
| 78 |
}
|
| 79 |
|
| 80 |
+
#tableList li:hover {
|
| 81 |
background-color: #d4dadf;
|
| 82 |
}
|
| 83 |
+
#tableList li.active {
|
| 84 |
+
background-color: #007bff; /* Bootstrap primary blue */
|
| 85 |
+
color: white;
|
| 86 |
+
font-weight: bold;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
|
| 90 |
#mainContent {
|
| 91 |
flex: 1;
|
|
|
|
| 100 |
display: flex;
|
| 101 |
flex-direction: column;
|
| 102 |
overflow: hidden; /* Child takes scroll */
|
| 103 |
+
gap: 15px; /* Add gap between content boxes */
|
| 104 |
}
|
| 105 |
|
| 106 |
+
#schemaDisplay, #dataDisplayContainer, #queryResultContainer {
|
| 107 |
+
background-color: #fff;
|
| 108 |
+
border: 1px solid #dee2e6;
|
| 109 |
+
border-radius: 5px; /* Slightly more rounded */
|
| 110 |
+
padding: 15px;
|
| 111 |
+
margin-bottom: 0; /* Remove margin, use gap instead */
|
| 112 |
+
overflow: auto; /* Allow scrolling within these areas */
|
| 113 |
+
box-shadow: 0 1px 3px rgba(0,0,0,0.05); /* Subtle shadow */
|
| 114 |
+
}
|
| 115 |
+
#schemaDisplay h4, #dataDisplayContainer h4, #queryResultContainer h4 {
|
| 116 |
+
margin-top: 0;
|
| 117 |
+
color: #495057;
|
| 118 |
+
border-bottom: 1px solid #eee;
|
| 119 |
+
padding-bottom: 10px;
|
| 120 |
+
margin-bottom: 15px;
|
| 121 |
}
|
| 122 |
|
| 123 |
+
|
| 124 |
+
#dataDisplayContainer {
|
| 125 |
+
flex: 1; /* Takes remaining space */
|
| 126 |
+
display: flex; /* Use flex for inner scroll */
|
| 127 |
+
flex-direction: column;
|
| 128 |
+
}
|
| 129 |
+
#dataDisplay {
|
| 130 |
+
flex: 1; /* Allow div itself to scroll */
|
| 131 |
+
overflow: auto;
|
| 132 |
+
min-height: 100px; /* Ensure it has some height */
|
| 133 |
+
}
|
| 134 |
|
| 135 |
|
| 136 |
#queryArea {
|
|
|
|
| 137 |
padding-top: 20px;
|
| 138 |
border-top: 1px solid #dee2e6;
|
| 139 |
+
background-color: #f8f9fa; /* Slight background */
|
| 140 |
+
padding: 15px;
|
| 141 |
+
border-radius: 5px;
|
| 142 |
+
box-shadow: 0 -1px 3px rgba(0,0,0,0.05);
|
| 143 |
}
|
| 144 |
+
#queryArea h4 {
|
| 145 |
+
margin-top: 0;
|
| 146 |
+
margin-bottom: 10px;
|
| 147 |
+
color: #495057;
|
| 148 |
+
}
|
| 149 |
|
| 150 |
#queryArea textarea {
|
| 151 |
width: 100%;
|
| 152 |
min-height: 80px;
|
| 153 |
padding: 10px;
|
| 154 |
+
border: 1px solid #ced4da; /* Match theme */
|
| 155 |
border-radius: 4px;
|
| 156 |
box-sizing: border-box;
|
| 157 |
font-family: monospace;
|
| 158 |
margin-bottom: 10px;
|
| 159 |
+
resize: vertical; /* Allow vertical resize */
|
| 160 |
}
|
| 161 |
|
| 162 |
#queryArea button {
|
| 163 |
padding: 10px 20px;
|
| 164 |
+
background-color: #28a745; /* Bootstrap success green */
|
| 165 |
color: white;
|
| 166 |
border: none;
|
| 167 |
border-radius: 4px;
|
| 168 |
cursor: pointer;
|
| 169 |
transition: background-color 0.2s;
|
| 170 |
+
font-weight: bold;
|
| 171 |
}
|
| 172 |
#queryArea button:hover {
|
| 173 |
+
background-color: #218838;
|
| 174 |
}
|
| 175 |
|
| 176 |
table {
|
| 177 |
width: 100%;
|
| 178 |
border-collapse: collapse;
|
| 179 |
+
margin-top: 0; /* Remove margin */
|
| 180 |
+
font-size: 0.9em; /* Slightly smaller table font */
|
| 181 |
}
|
| 182 |
|
| 183 |
th, td {
|
| 184 |
+
border: 1px solid #e9ecef; /* Lighter border */
|
| 185 |
+
padding: 10px 12px; /* Adjust padding */
|
| 186 |
text-align: left;
|
| 187 |
white-space: nowrap;
|
| 188 |
+
max-width: 250px; /* Prevent very wide columns */
|
| 189 |
+
overflow: hidden;
|
| 190 |
+
text-overflow: ellipsis;
|
| 191 |
}
|
| 192 |
|
| 193 |
th {
|
| 194 |
+
background-color: #f8f9fa; /* Very light header */
|
| 195 |
+
font-weight: 600; /* Slightly bolder */
|
| 196 |
+
position: sticky; /* Sticky headers */
|
| 197 |
+
top: 0;
|
| 198 |
+
z-index: 1;
|
| 199 |
}
|
| 200 |
|
| 201 |
tr:nth-child(even) {
|
| 202 |
+
background-color: #fdfdfe; /* Very subtle striping */
|
| 203 |
}
|
| 204 |
|
| 205 |
+
#statusMessage {
|
| 206 |
+
padding: 10px 15px;
|
| 207 |
+
margin-top: 15px;
|
| 208 |
+
border-radius: 4px;
|
| 209 |
+
display: none; /* Hidden by default */
|
| 210 |
+
font-size: 0.9em;
|
| 211 |
+
}
|
| 212 |
|
| 213 |
#statusMessage.success {
|
| 214 |
+
background-color: #d1e7dd;
|
| 215 |
+
color: #0f5132;
|
| 216 |
+
border: 1px solid #badbcc;
|
| 217 |
}
|
| 218 |
|
| 219 |
#statusMessage.error {
|
| 220 |
background-color: #f8d7da;
|
| 221 |
+
color: #842029;
|
| 222 |
+
border: 1px solid #f5c2c7;
|
| 223 |
}
|
| 224 |
+
/* Loader animation */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
@keyframes spin {
|
| 226 |
0% { transform: rotate(0deg); }
|
| 227 |
100% { transform: rotate(360deg); }
|
|
|
|
| 231 |
<body>
|
| 232 |
|
| 233 |
<header>
|
| 234 |
+
<span>🦆 DuckDB Explorer</span>
|
| 235 |
+
<div class="loader" id="loadingIndicator"></div>
|
|
|
|
|
|
|
| 236 |
</header>
|
| 237 |
|
| 238 |
<div class="container">
|
| 239 |
<aside id="sidebar">
|
| 240 |
<h3>Tables</h3>
|
| 241 |
<ul id="tableList">
|
| 242 |
+
<li>Loading...</li>
|
| 243 |
</ul>
|
| 244 |
</aside>
|
| 245 |
|
|
|
|
| 247 |
<div class="content-area">
|
| 248 |
<div id="schemaDisplay">
|
| 249 |
<h4>Schema</h4>
|
| 250 |
+
<p>Select a table from the list.</p>
|
| 251 |
<table id="schemaTable"></table>
|
| 252 |
</div>
|
| 253 |
<div id="dataDisplayContainer">
|
| 254 |
<h4>Data <span id="tableDataHeader"></span></h4>
|
| 255 |
+
<p>Select a table from the list.</p>
|
| 256 |
+
<div id="dataDisplay">
|
| 257 |
<table id="dataTable"></table>
|
| 258 |
</div>
|
| 259 |
</div>
|
| 260 |
<div id="queryResultContainer" style="display: none;">
|
| 261 |
<h4>Query Result</h4>
|
| 262 |
+
<div id="queryResultDisplay">
|
| 263 |
<table id="queryResultTable"></table>
|
| 264 |
</div>
|
| 265 |
</div>
|
| 266 |
</div>
|
| 267 |
|
| 268 |
<div id="queryArea">
|
| 269 |
+
<h4>Custom SQL Query (SELECT/SHOW/PRAGMA only)</h4>
|
| 270 |
+
<textarea id="sqlInput" placeholder="Enter your SELECT query here... e.g., SELECT * FROM table_name LIMIT 10"></textarea>
|
| 271 |
<button id="runSqlButton">Run SQL</button>
|
| 272 |
</div>
|
| 273 |
|
|
|
|
| 276 |
</div>
|
| 277 |
|
| 278 |
<script>
|
| 279 |
+
// --- Keep the existing element variables ---
|
|
|
|
| 280 |
const tableList = document.getElementById('tableList');
|
| 281 |
const schemaDisplay = document.getElementById('schemaDisplay');
|
| 282 |
const schemaTable = document.getElementById('schemaTable');
|
|
|
|
| 292 |
const statusMessage = document.getElementById('statusMessage');
|
| 293 |
const loadingIndicator = document.getElementById('loadingIndicator');
|
| 294 |
|
| 295 |
+
// --- API URL is now relative ---
|
| 296 |
+
const API_BASE_URL = '';
|
| 297 |
let currentTables = [];
|
| 298 |
let selectedTable = null;
|
| 299 |
|
| 300 |
+
// --- Utility Functions (keep existing showLoader, showStatus, clearStatus, renderTable, renderSchema) ---
|
|
|
|
| 301 |
function showLoader(show) {
|
| 302 |
loadingIndicator.style.display = show ? 'inline-block' : 'none';
|
| 303 |
}
|
|
|
|
| 306 |
statusMessage.textContent = message;
|
| 307 |
statusMessage.className = isError ? 'error' : 'success';
|
| 308 |
statusMessage.style.display = 'block';
|
|
|
|
| 309 |
setTimeout(() => { statusMessage.style.display = 'none'; }, 5000);
|
| 310 |
}
|
| 311 |
|
|
|
|
| 317 |
async function fetchAPI(endpoint, options = {}) {
|
| 318 |
showLoader(true);
|
| 319 |
clearStatus();
|
| 320 |
+
const url = `${API_BASE_URL}${endpoint}`; // API_BASE_URL is now ''
|
| 321 |
try {
|
| 322 |
const response = await fetch(url, options);
|
| 323 |
if (!response.ok) {
|
|
|
|
| 325 |
try {
|
| 326 |
const errorJson = await response.json();
|
| 327 |
errorDetail += ` - ${errorJson.detail || JSON.stringify(errorJson)}`;
|
| 328 |
+
} catch (e) { /* Ignore */ }
|
| 329 |
throw new Error(errorDetail);
|
| 330 |
}
|
|
|
|
| 331 |
if (response.headers.get("content-type")?.includes("application/json")) {
|
| 332 |
return await response.json();
|
| 333 |
}
|
| 334 |
+
// Handle potential non-JSON success responses if needed
|
| 335 |
+
return await response.text();
|
| 336 |
} catch (error) {
|
| 337 |
console.error('API Fetch Error:', error);
|
| 338 |
showStatus(`Error: ${error.message}`, true);
|
| 339 |
+
throw error;
|
| 340 |
} finally {
|
| 341 |
showLoader(false);
|
| 342 |
}
|
| 343 |
}
|
| 344 |
|
| 345 |
function renderTable(data, tableElement) {
|
| 346 |
+
tableElement.innerHTML = '';
|
|
|
|
| 347 |
if (!data || data.length === 0) {
|
| 348 |
tableElement.innerHTML = '<tbody><tr><td>No data available.</td></tr></tbody>';
|
| 349 |
return;
|
| 350 |
}
|
|
|
|
| 351 |
const headers = Object.keys(data[0]);
|
| 352 |
const thead = tableElement.createTHead();
|
| 353 |
const headerRow = thead.insertRow();
|
|
|
|
| 356 |
th.textContent = headerText;
|
| 357 |
headerRow.appendChild(th);
|
| 358 |
});
|
|
|
|
| 359 |
const tbody = tableElement.createTBody();
|
| 360 |
data.forEach(rowData => {
|
| 361 |
const row = tbody.insertRow();
|
| 362 |
headers.forEach(header => {
|
| 363 |
const cell = row.insertCell();
|
| 364 |
+
const value = rowData[header];
|
| 365 |
+
// Better null/undefined check and string conversion
|
| 366 |
+
cell.textContent = (value === null || value === undefined) ? 'NULL' : String(value);
|
| 367 |
+
});
|
| 368 |
});
|
| 369 |
}
|
| 370 |
|
| 371 |
function renderSchema(schemaData) {
|
| 372 |
+
const tableElement = schemaTable;
|
| 373 |
+
tableElement.innerHTML = '';
|
|
|
|
| 374 |
if (!schemaData || !schemaData.columns || schemaData.columns.length === 0) {
|
| 375 |
schemaDisplay.innerHTML = '<h4>Schema</h4><p>No schema information available.</p>';
|
| 376 |
return;
|
| 377 |
}
|
| 378 |
+
schemaDisplay.innerHTML = '<h4>Schema</h4>';
|
|
|
|
|
|
|
| 379 |
const thead = tableElement.createTHead();
|
| 380 |
const headerRow = thead.insertRow();
|
| 381 |
['Name', 'Type'].forEach(headerText => {
|
|
|
|
| 383 |
th.textContent = headerText;
|
| 384 |
headerRow.appendChild(th);
|
| 385 |
});
|
|
|
|
| 386 |
const tbody = tableElement.createTBody();
|
| 387 |
schemaData.columns.forEach(column => {
|
| 388 |
const row = tbody.insertRow();
|
|
|
|
| 391 |
});
|
| 392 |
}
|
| 393 |
|
| 394 |
+
// --- Event Handlers (Modified) ---
|
|
|
|
| 395 |
|
| 396 |
async function loadTables() {
|
| 397 |
+
// No need to get API_BASE_URL from input anymore
|
| 398 |
+
tableList.innerHTML = '<li>Loading tables...</li>'; // Indicate loading
|
| 399 |
+
schemaTable.innerHTML = ''; // Clear schema
|
| 400 |
+
dataTable.innerHTML = ''; // Clear data
|
| 401 |
+
tableDataHeader.textContent = '';
|
| 402 |
+
queryResultContainer.style.display = 'none';
|
| 403 |
try {
|
|
|
|
|
|
|
| 404 |
currentTables = await fetchAPI('/tables');
|
| 405 |
displayTables(currentTables);
|
| 406 |
+
showStatus("Tables loaded.", false);
|
| 407 |
+
// Clear placeholder texts
|
| 408 |
+
if (currentTables.length > 0) {
|
| 409 |
+
schemaDisplay.innerHTML = '<h4>Schema</h4><p>Select a table from the list.</p>';
|
| 410 |
+
dataDisplayContainer.querySelector('p').style.display = 'block'; // Show prompt
|
| 411 |
+
} else {
|
| 412 |
+
schemaDisplay.innerHTML = '<h4>Schema</h4><p>No tables found in the database.</p>';
|
| 413 |
+
dataDisplayContainer.querySelector('p').style.display = 'block';
|
| 414 |
+
}
|
| 415 |
} catch (error) {
|
| 416 |
tableList.innerHTML = '<li>Error loading tables.</li>';
|
| 417 |
}
|
| 418 |
}
|
| 419 |
|
| 420 |
+
// --- displayTables and handleTableSelection remain the same ---
|
| 421 |
function displayTables(tables) {
|
| 422 |
tableList.innerHTML = ''; // Clear list
|
| 423 |
if (tables.length === 0) {
|
|
|
|
| 434 |
}
|
| 435 |
|
| 436 |
async function handleTableSelection(listItem) {
|
|
|
|
| 437 |
const currentActive = tableList.querySelector('.active');
|
| 438 |
if (currentActive) {
|
| 439 |
currentActive.classList.remove('active');
|
| 440 |
}
|
|
|
|
| 441 |
listItem.classList.add('active');
|
| 442 |
|
| 443 |
selectedTable = listItem.dataset.tableName;
|
| 444 |
if (!selectedTable) return;
|
| 445 |
|
| 446 |
+
queryResultContainer.style.display = 'none';
|
| 447 |
+
dataDisplayContainer.style.display = 'flex'; // Make sure it's flex
|
| 448 |
+
dataDisplayContainer.querySelector('p').style.display = 'none'; // Hide prompt
|
| 449 |
|
| 450 |
tableDataHeader.textContent = `for table "${selectedTable}"`;
|
| 451 |
+
schemaDisplay.innerHTML = '<h4>Schema</h4>'; // Keep header
|
| 452 |
schemaTable.innerHTML = '<tbody><tr><td>Loading schema...</td></tr></tbody>';
|
| 453 |
dataTable.innerHTML = '<tbody><tr><td>Loading data...</td></tr></tbody>';
|
| 454 |
|
| 455 |
try {
|
| 456 |
+
// Fetch schema and data concurrently
|
| 457 |
+
const [schemaResponse, tableDataResponse] = await Promise.all([
|
| 458 |
fetchAPI(`/tables/${selectedTable}/schema`),
|
| 459 |
+
fetchAPI(`/tables/${selectedTable}?limit=100`) // Default limit
|
| 460 |
]);
|
| 461 |
+
renderSchema(schemaResponse);
|
| 462 |
+
renderTable(tableDataResponse, dataTable);
|
| 463 |
} catch (error) {
|
| 464 |
+
// Error already shown by fetchAPI
|
| 465 |
+
schemaTable.innerHTML = '<tbody><tr><td colspan="2">Error loading schema.</td></tr></tbody>';
|
| 466 |
dataTable.innerHTML = '<tbody><tr><td>Error loading data.</td></tr></tbody>';
|
| 467 |
}
|
| 468 |
}
|
| 469 |
|
| 470 |
+
|
| 471 |
+
// --- runCustomQuery remains mostly the same ---
|
| 472 |
async function runCustomQuery() {
|
| 473 |
const sql = sqlInput.value.trim();
|
| 474 |
if (!sql) {
|
| 475 |
showStatus("SQL query cannot be empty.", true);
|
| 476 |
return;
|
| 477 |
}
|
| 478 |
+
// No need to check API_BASE_URL anymore
|
|
|
|
|
|
|
|
|
|
| 479 |
|
| 480 |
dataDisplayContainer.style.display = 'none'; // Hide table data
|
| 481 |
+
dataDisplayContainer.querySelector('p').style.display = 'none'; // Hide prompt
|
| 482 |
queryResultContainer.style.display = 'block'; // Show query results area
|
| 483 |
queryResultTable.innerHTML = '<tbody><tr><td>Running query...</td></tr></tbody>';
|
| 484 |
|
|
|
|
| 494 |
renderTable(resultData, queryResultTable);
|
| 495 |
showStatus("Query executed successfully.", false);
|
| 496 |
} catch (error) {
|
| 497 |
+
queryResultTable.innerHTML = '<tbody><tr><td>Error executing query. See status message.</td></tr></tbody>';
|
| 498 |
+
// Error is shown by fetchAPI
|
| 499 |
}
|
| 500 |
}
|
| 501 |
|
| 502 |
// --- Initial Setup ---
|
| 503 |
+
// Remove connectButton listener
|
| 504 |
runSqlButton.onclick = runCustomQuery;
|
| 505 |
|
| 506 |
+
// Load tables automatically when the page loads
|
| 507 |
+
document.addEventListener('DOMContentLoaded', loadTables);
|
|
|
|
|
|
|
| 508 |
|
| 509 |
</script>
|
| 510 |
|
main.py
CHANGED
|
@@ -1,18 +1,21 @@
|
|
| 1 |
-
# ... (keep existing imports and setup) ...
|
| 2 |
import duckdb
|
| 3 |
import os
|
| 4 |
from fastapi import FastAPI, HTTPException, Request, Path as FastPath, Body
|
|
|
|
| 5 |
from fastapi.responses import FileResponse, StreamingResponse
|
|
|
|
|
|
|
| 6 |
from pydantic import BaseModel, Field
|
| 7 |
from typing import List, Dict, Any, Optional
|
| 8 |
import logging
|
| 9 |
import io
|
| 10 |
import asyncio
|
| 11 |
-
from contextlib import contextmanager #
|
| 12 |
|
| 13 |
# --- Configuration ---
|
| 14 |
DATABASE_PATH = os.environ.get("DUCKDB_PATH", "data/mydatabase.db")
|
| 15 |
DATA_DIR = "data"
|
|
|
|
| 16 |
|
| 17 |
# Ensure data directory exists
|
| 18 |
os.makedirs(DATA_DIR, exist_ok=True)
|
|
@@ -28,6 +31,18 @@ app = FastAPI(
|
|
| 28 |
version="0.1.0"
|
| 29 |
)
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
# --- Database Connection (using context manager for safety) ---
|
| 32 |
@contextmanager
|
| 33 |
def get_db_context():
|
|
@@ -48,7 +63,7 @@ def get_db_context():
|
|
| 48 |
if conn:
|
| 49 |
conn.close()
|
| 50 |
|
| 51 |
-
# --- Pydantic Models ---
|
| 52 |
class ColumnDefinition(BaseModel):
|
| 53 |
name: str
|
| 54 |
type: str
|
|
@@ -76,17 +91,13 @@ class ApiResponse(BaseModel):
|
|
| 76 |
message: str
|
| 77 |
details: Optional[Any] = None
|
| 78 |
|
| 79 |
-
# --- Helper Functions ---
|
| 80 |
def safe_identifier(name: str) -> str:
|
| 81 |
"""Quotes an identifier safely using DuckDB."""
|
| 82 |
-
# Basic check
|
| 83 |
if not name or not isinstance(name, str):
|
| 84 |
raise HTTPException(status_code=400, detail=f"Invalid identifier provided: {name}")
|
| 85 |
-
# Use DuckDB's quoting mechanism
|
| 86 |
try:
|
| 87 |
-
# Use a temporary in-memory connection for quoting safely
|
| 88 |
with duckdb.connect(':memory:') as temp_conn:
|
| 89 |
-
# Use sql() which returns a relation, then fetch the result
|
| 90 |
quoted = temp_conn.sql(f"SELECT '{name}'::IDENTIFIER").fetchone()
|
| 91 |
if quoted:
|
| 92 |
return quoted[0]
|
|
@@ -94,7 +105,6 @@ def safe_identifier(name: str) -> str:
|
|
| 94 |
raise HTTPException(status_code=500, detail="Failed to quote identifier")
|
| 95 |
except duckdb.Error as e:
|
| 96 |
logger.error(f"Error quoting identifier '{name}': {e}")
|
| 97 |
-
# Fallback or re-raise depending on policy, here we raise
|
| 98 |
raise HTTPException(status_code=400, detail=f"Invalid identifier '{name}': {e}")
|
| 99 |
|
| 100 |
def generate_column_sql(columns: List[ColumnDefinition]) -> str:
|
|
@@ -102,64 +112,61 @@ def generate_column_sql(columns: List[ColumnDefinition]) -> str:
|
|
| 102 |
defs = []
|
| 103 |
for col in columns:
|
| 104 |
col_name_safe = safe_identifier(col.name)
|
| 105 |
-
# More robust type validation needed for production
|
| 106 |
allowed_types_prefix = ['INTEGER', 'VARCHAR', 'TEXT', 'BOOLEAN', 'FLOAT', 'DOUBLE', 'DATE', 'TIMESTAMP', 'BLOB', 'BIGINT', 'DECIMAL', 'LIST', 'STRUCT', 'MAP', 'UNION']
|
| 107 |
type_upper = col.type.strip().upper()
|
| 108 |
-
|
| 109 |
is_allowed = False
|
| 110 |
for prefix in allowed_types_prefix:
|
| 111 |
-
# Allow types like VARCHAR(255), DECIMAL(10,2), LIST<INT>, STRUCT<a INT> etc.
|
| 112 |
if type_upper.startswith(prefix):
|
| 113 |
is_allowed = True
|
| 114 |
break
|
| 115 |
-
|
| 116 |
if not is_allowed:
|
| 117 |
-
# Very basic check, expand as needed
|
| 118 |
raise HTTPException(status_code=400, detail=f"Unsupported or potentially invalid data type: {col.type}")
|
| 119 |
-
|
| 120 |
-
defs.append(f"{col_name_safe} {col.type}") # Pass type string directly
|
| 121 |
return ", ".join(defs)
|
| 122 |
|
| 123 |
def result_to_dict(cursor_description, rows):
|
| 124 |
"""Converts cursor results (description + rows) to a list of dictionaries."""
|
|
|
|
|
|
|
| 125 |
column_names = [desc[0] for desc in cursor_description]
|
| 126 |
return [dict(zip(column_names, row)) for row in rows]
|
| 127 |
|
| 128 |
-
# --- API Endpoints ---
|
| 129 |
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
# --- NEW ENDPOINT ---
|
| 136 |
@app.get("/tables", summary="List Tables", response_model=List[str])
|
| 137 |
async def list_tables():
|
| 138 |
"""Lists all tables in the default schema."""
|
| 139 |
try:
|
| 140 |
with get_db_context() as conn:
|
| 141 |
-
|
| 142 |
-
tables = conn.execute("SELECT table_name FROM information_schema.tables WHERE table_schema = 'main'").fetchall()
|
| 143 |
return [table[0] for table in tables]
|
| 144 |
except duckdb.Error as e:
|
| 145 |
logger.error(f"Error listing tables: {e}")
|
| 146 |
raise HTTPException(status_code=500, detail=f"Error listing tables: {e}")
|
| 147 |
|
| 148 |
-
# --- NEW ENDPOINT ---
|
| 149 |
@app.get("/tables/{table_name}/schema", summary="Get Table Schema", response_model=TableSchemaResponse)
|
| 150 |
async def get_table_schema(
|
| 151 |
table_name: str = FastPath(..., description="Name of the table")
|
| 152 |
):
|
| 153 |
"""Gets the schema (column names and types) for a specific table."""
|
| 154 |
table_name_safe = safe_identifier(table_name)
|
| 155 |
-
# Use PRAGMA for schema info
|
| 156 |
sql = f"PRAGMA table_info({table_name_safe});"
|
| 157 |
try:
|
| 158 |
with get_db_context() as conn:
|
| 159 |
result = conn.execute(sql).fetchall()
|
| 160 |
if not result:
|
| 161 |
raise HTTPException(status_code=404, detail=f"Table '{table_name}' not found or has no columns.")
|
| 162 |
-
# PRAGMA table_info columns: cid, name, type, notnull, dflt_value, pk
|
| 163 |
columns = [ColumnDefinition(name=row[1], type=row[2]) for row in result]
|
| 164 |
return TableSchemaResponse(columns=columns)
|
| 165 |
except duckdb.CatalogException as e:
|
|
@@ -168,28 +175,25 @@ async def get_table_schema(
|
|
| 168 |
logger.error(f"Error getting schema for table '{table_name}': {e}")
|
| 169 |
raise HTTPException(status_code=400, detail=f"Error getting table schema: {e}")
|
| 170 |
|
| 171 |
-
# --- NEW ENDPOINT ---
|
| 172 |
@app.post("/query", summary="Execute Read-Only SQL Query")
|
| 173 |
async def execute_query(query_request: SQLQueryRequest):
|
| 174 |
"""Executes a provided SQL query (read-only enforced)."""
|
| 175 |
sql = query_request.sql.strip()
|
| 176 |
-
|
| 177 |
-
# **Security:** Basic check to prevent modification queries.
|
| 178 |
-
# This is NOT foolproof. A robust solution needs proper SQL parsing or
|
| 179 |
-
# database roles/permissions restricting the API user.
|
| 180 |
forbidden_keywords = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'ATTACH', 'DETACH', 'COPY', 'EXPORT', 'IMPORT']
|
| 181 |
sql_upper = sql.upper()
|
| 182 |
if any(keyword in sql_upper for keyword in forbidden_keywords):
|
| 183 |
raise HTTPException(status_code=403, detail="Only SELECT queries are allowed.")
|
| 184 |
-
if not sql_upper.startswith('SELECT') and not sql_upper.startswith('WITH'):
|
| 185 |
-
|
|
|
|
| 186 |
|
| 187 |
try:
|
| 188 |
logger.info(f"Executing user SQL: {sql}")
|
| 189 |
with get_db_context() as conn:
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
|
|
|
| 193 |
data = result_to_dict(description, result)
|
| 194 |
return data
|
| 195 |
except duckdb.Error as e:
|
|
@@ -199,38 +203,37 @@ async def execute_query(query_request: SQLQueryRequest):
|
|
| 199 |
logger.error(f"Unexpected error executing user query: {e}")
|
| 200 |
raise HTTPException(status_code=500, detail="An unexpected error occurred during query execution.")
|
| 201 |
|
| 202 |
-
|
| 203 |
@app.post("/tables/{table_name}", summary="Create Table", response_model=ApiResponse, status_code=201)
|
| 204 |
async def create_table(
|
| 205 |
table_name: str = FastPath(..., description="Name of the table to create"),
|
| 206 |
schema: CreateTableRequest = ...,
|
| 207 |
):
|
| 208 |
-
"""Creates a
|
| 209 |
table_name_safe = safe_identifier(table_name)
|
| 210 |
if not schema.columns:
|
| 211 |
raise HTTPException(status_code=400, detail="Table must have at least one column.")
|
| 212 |
-
|
| 213 |
try:
|
| 214 |
columns_sql = generate_column_sql(schema.columns)
|
| 215 |
-
sql = f"CREATE OR REPLACE TABLE {table_name_safe} ({columns_sql});"
|
| 216 |
logger.info(f"Executing SQL: {sql}")
|
| 217 |
with get_db_context() as conn:
|
| 218 |
conn.execute(sql)
|
| 219 |
return {"message": f"Table '{table_name}' created or replaced successfully."}
|
| 220 |
-
except HTTPException as e:
|
| 221 |
-
raise e
|
| 222 |
except duckdb.Error as e:
|
| 223 |
-
logger.error(f"Error creating table '{table_name}': {e}")
|
| 224 |
-
raise HTTPException(status_code=400, detail=f"Error creating table: {e}")
|
| 225 |
except Exception as e:
|
| 226 |
-
logger.error(f"Unexpected error creating table '{table_name}': {e}")
|
| 227 |
raise HTTPException(status_code=500, detail="An unexpected error occurred.")
|
| 228 |
|
|
|
|
| 229 |
@app.get("/tables/{table_name}", summary="Read Table Data")
|
| 230 |
async def read_table(
|
| 231 |
table_name: str = FastPath(..., description="Name of the table to read from"),
|
| 232 |
-
limit: Optional[int] = 100,
|
| 233 |
-
offset: Optional[int] = 0
|
| 234 |
):
|
| 235 |
"""Reads and returns rows from a specified table. Supports limit and offset."""
|
| 236 |
table_name_safe = safe_identifier(table_name)
|
|
@@ -243,12 +246,12 @@ async def read_table(
|
|
| 243 |
sql += " OFFSET ?"
|
| 244 |
params.append(offset)
|
| 245 |
sql += ";"
|
| 246 |
-
|
| 247 |
try:
|
| 248 |
logger.info(f"Executing SQL: {sql} with params: {params}")
|
| 249 |
with get_db_context() as conn:
|
| 250 |
-
|
| 251 |
-
|
|
|
|
| 252 |
data = result_to_dict(description, result)
|
| 253 |
return data
|
| 254 |
except duckdb.CatalogException as e:
|
|
@@ -260,39 +263,34 @@ async def read_table(
|
|
| 260 |
logger.error(f"Unexpected error reading table '{table_name}': {e}")
|
| 261 |
raise HTTPException(status_code=500, detail="An unexpected error occurred.")
|
| 262 |
|
|
|
|
|
|
|
|
|
|
| 263 |
|
| 264 |
@app.post("/tables/{table_name}/rows", summary="Create Rows", response_model=ApiResponse, status_code=201)
|
| 265 |
async def create_rows(
|
| 266 |
table_name: str = FastPath(..., description="Name of the table to insert into"),
|
| 267 |
request: CreateRowRequest = ...,
|
| 268 |
):
|
| 269 |
-
"""Inserts one or more rows into the specified table."""
|
| 270 |
table_name_safe = safe_identifier(table_name)
|
| 271 |
if not request.rows:
|
| 272 |
raise HTTPException(status_code=400, detail="No rows provided to insert.")
|
| 273 |
-
|
| 274 |
-
# Assume all rows have the same columns based on the first row
|
| 275 |
columns = list(request.rows[0].keys())
|
| 276 |
columns_safe = [safe_identifier(col) for col in columns]
|
| 277 |
placeholders = ", ".join(["?"] * len(columns))
|
| 278 |
columns_sql = ", ".join(columns_safe)
|
| 279 |
-
|
| 280 |
sql = f"INSERT INTO {table_name_safe} ({columns_sql}) VALUES ({placeholders});"
|
| 281 |
-
|
| 282 |
-
# Convert list of dicts to list of lists/tuples for executemany
|
| 283 |
params_list = []
|
| 284 |
for row_dict in request.rows:
|
| 285 |
if list(row_dict.keys()) != columns:
|
| 286 |
raise HTTPException(status_code=400, detail="All rows must have the same columns in the same order.")
|
| 287 |
params_list.append(list(row_dict.values()))
|
| 288 |
-
|
| 289 |
try:
|
| 290 |
logger.info(f"Executing SQL: {sql} for {len(params_list)} rows")
|
| 291 |
-
with get_db_context() as conn:
|
| 292 |
conn.executemany(sql, params_list)
|
| 293 |
-
# Removed commit - context manager handles it
|
| 294 |
return {"message": f"Successfully inserted {len(params_list)} rows into '{table_name}'."}
|
| 295 |
-
except duckdb.CatalogException
|
| 296 |
raise HTTPException(status_code=404, detail=f"Table '{table_name}' not found.")
|
| 297 |
except duckdb.Error as e:
|
| 298 |
logger.error(f"Error inserting rows into '{table_name}': {e}")
|
|
@@ -301,88 +299,22 @@ async def create_rows(
|
|
| 301 |
logger.error(f"Unexpected error inserting rows into '{table_name}': {e}")
|
| 302 |
raise HTTPException(status_code=500, detail="An unexpected error occurred.")
|
| 303 |
|
| 304 |
-
|
| 305 |
-
@app.put("/tables/{table_name}/rows", summary="Update Rows", response_model=ApiResponse)
|
| 306 |
-
async def update_rows(
|
| 307 |
-
table_name: str = FastPath(..., description="Name of the table to update"),
|
| 308 |
-
request: UpdateRowRequest = ...,
|
| 309 |
-
):
|
| 310 |
-
"""Updates rows in the table based on a condition."""
|
| 311 |
-
table_name_safe = safe_identifier(table_name)
|
| 312 |
-
if not request.updates:
|
| 313 |
-
raise HTTPException(status_code=400, detail="No updates provided.")
|
| 314 |
-
if not request.condition:
|
| 315 |
-
raise HTTPException(status_code=400, detail="Update condition (WHERE clause) is required.")
|
| 316 |
-
|
| 317 |
-
set_clauses = []
|
| 318 |
-
params = []
|
| 319 |
-
for col, value in request.updates.items():
|
| 320 |
-
set_clauses.append(f"{safe_identifier(col)} = ?")
|
| 321 |
-
params.append(value)
|
| 322 |
-
|
| 323 |
-
set_sql = ", ".join(set_clauses)
|
| 324 |
-
# WARNING: Injecting request.condition directly is a security risk.
|
| 325 |
-
# Use parameters for values, but condition structure still needs care.
|
| 326 |
-
sql = f"UPDATE {table_name_safe} SET {set_sql} WHERE {request.condition};" # Condition not parameterized here
|
| 327 |
-
|
| 328 |
-
try:
|
| 329 |
-
logger.info(f"Executing SQL: {sql} with params: {params}")
|
| 330 |
-
with get_db_context() as conn:
|
| 331 |
-
conn.execute(sql, params)
|
| 332 |
-
# Removed commit
|
| 333 |
-
return {"message": f"Rows in '{table_name}' updated successfully based on condition."}
|
| 334 |
-
except duckdb.CatalogException as e:
|
| 335 |
-
raise HTTPException(status_code=404, detail=f"Table '{table_name}' not found.")
|
| 336 |
-
except duckdb.Error as e:
|
| 337 |
-
logger.error(f"Error updating rows in '{table_name}': {e}")
|
| 338 |
-
raise HTTPException(status_code=400, detail=f"Error updating rows: {e}")
|
| 339 |
-
except Exception as e:
|
| 340 |
-
logger.error(f"Unexpected error updating rows in '{table_name}': {e}")
|
| 341 |
-
raise HTTPException(status_code=500, detail="An unexpected error occurred.")
|
| 342 |
-
|
| 343 |
-
@app.delete("/tables/{table_name}/rows", summary="Delete Rows", response_model=ApiResponse)
|
| 344 |
-
async def delete_rows(
|
| 345 |
-
table_name: str = FastPath(..., description="Name of the table to delete from"),
|
| 346 |
-
request: DeleteRowRequest = ...,
|
| 347 |
-
):
|
| 348 |
-
"""Deletes rows from the table based on a condition."""
|
| 349 |
-
table_name_safe = safe_identifier(table_name)
|
| 350 |
-
if not request.condition:
|
| 351 |
-
raise HTTPException(status_code=400, detail="Delete condition (WHERE clause) is required.")
|
| 352 |
-
|
| 353 |
-
# WARNING: Injecting request.condition directly is a security risk.
|
| 354 |
-
sql = f"DELETE FROM {table_name_safe} WHERE {request.condition};" # Condition not parameterized here
|
| 355 |
-
|
| 356 |
-
try:
|
| 357 |
-
logger.info(f"Executing SQL: {sql}")
|
| 358 |
-
with get_db_context() as conn:
|
| 359 |
-
conn.execute(sql)
|
| 360 |
-
# Removed commit
|
| 361 |
-
return {"message": f"Rows from '{table_name}' deleted successfully based on condition."}
|
| 362 |
-
except duckdb.CatalogException as e:
|
| 363 |
-
raise HTTPException(status_code=404, detail=f"Table '{table_name}' not found.")
|
| 364 |
-
except duckdb.Error as e:
|
| 365 |
-
logger.error(f"Error deleting rows from '{table_name}': {e}")
|
| 366 |
-
raise HTTPException(status_code=400, detail=f"Error deleting rows: {e}")
|
| 367 |
-
except Exception as e:
|
| 368 |
-
logger.error(f"Unexpected error deleting rows from '{table_name}': {e}")
|
| 369 |
-
raise HTTPException(status_code=500, detail="An unexpected error occurred.")
|
| 370 |
|
| 371 |
# --- Download Endpoints ---
|
| 372 |
@app.get("/download/table/{table_name}", summary="Download Table as CSV")
|
| 373 |
async def download_table_csv(
|
| 374 |
table_name: str = FastPath(..., description="Name of the table to download")
|
| 375 |
):
|
| 376 |
-
"""Downloads the entire content of a table as a CSV file."""
|
| 377 |
table_name_safe = safe_identifier(table_name)
|
| 378 |
sql = f"COPY (SELECT * FROM {table_name_safe}) TO STDOUT (FORMAT CSV, HEADER)"
|
| 379 |
|
| 380 |
async def stream_csv_data():
|
| 381 |
try:
|
| 382 |
-
# Use pandas for CSV conversion in-memory
|
| 383 |
with get_db_context() as conn:
|
| 384 |
# Check if table exists before fetching
|
| 385 |
conn.execute(f"SELECT 1 FROM {table_name_safe} LIMIT 0")
|
|
|
|
| 386 |
df = conn.execute(f"SELECT * FROM {table_name_safe}").df()
|
| 387 |
|
| 388 |
all_data_io = io.StringIO()
|
|
@@ -392,13 +324,11 @@ async def download_table_csv(
|
|
| 392 |
chunk_size = 8192
|
| 393 |
while True:
|
| 394 |
chunk = all_data_io.read(chunk_size)
|
| 395 |
-
if not chunk:
|
| 396 |
-
|
| 397 |
-
yield chunk.encode('utf-8') # Encode to bytes for streaming response
|
| 398 |
await asyncio.sleep(0)
|
| 399 |
all_data_io.close()
|
| 400 |
-
|
| 401 |
-
except duckdb.CatalogException as e:
|
| 402 |
yield f"Error: Table '{table_name}' not found.".encode('utf-8')
|
| 403 |
logger.error(f"Error downloading table '{table_name}': Table not found.")
|
| 404 |
except duckdb.Error as e:
|
|
@@ -417,21 +347,18 @@ async def download_table_csv(
|
|
| 417 |
|
| 418 |
@app.get("/download/database", summary="Download Database File")
|
| 419 |
async def download_database_file():
|
| 420 |
-
"""Downloads the entire DuckDB database file."""
|
| 421 |
if not os.path.exists(DATABASE_PATH):
|
| 422 |
raise HTTPException(status_code=404, detail="Database file not found.")
|
| 423 |
logger.warning("Attempting to download database file. Ensure no active writes are occurring.")
|
| 424 |
return FileResponse(
|
| 425 |
path=DATABASE_PATH,
|
| 426 |
filename=os.path.basename(DATABASE_PATH),
|
| 427 |
-
media_type="application/vnd.duckdb.database"
|
| 428 |
)
|
| 429 |
|
| 430 |
-
|
| 431 |
# --- Health Check ---
|
| 432 |
@app.get("/health", summary="Health Check", response_model=ApiResponse)
|
| 433 |
async def health_check():
|
| 434 |
-
"""Checks if the API and database connection are working."""
|
| 435 |
try:
|
| 436 |
with get_db_context() as conn:
|
| 437 |
conn.execute("SELECT 1")
|
|
|
|
|
|
|
| 1 |
import duckdb
|
| 2 |
import os
|
| 3 |
from fastapi import FastAPI, HTTPException, Request, Path as FastPath, Body
|
| 4 |
+
# --- Add FileResponse ---
|
| 5 |
from fastapi.responses import FileResponse, StreamingResponse
|
| 6 |
+
# --- Add CORS ---
|
| 7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
from pydantic import BaseModel, Field
|
| 9 |
from typing import List, Dict, Any, Optional
|
| 10 |
import logging
|
| 11 |
import io
|
| 12 |
import asyncio
|
| 13 |
+
from contextlib import contextmanager # Ensure this is imported
|
| 14 |
|
| 15 |
# --- Configuration ---
|
| 16 |
DATABASE_PATH = os.environ.get("DUCKDB_PATH", "data/mydatabase.db")
|
| 17 |
DATA_DIR = "data"
|
| 18 |
+
HTML_FILE_PATH = "index.html" # Path relative to main.py
|
| 19 |
|
| 20 |
# Ensure data directory exists
|
| 21 |
os.makedirs(DATA_DIR, exist_ok=True)
|
|
|
|
| 31 |
version="0.1.0"
|
| 32 |
)
|
| 33 |
|
| 34 |
+
# --- Add CORS Middleware ---
|
| 35 |
+
# Allows requests from any origin in this example.
|
| 36 |
+
# Restrict this in a production environment!
|
| 37 |
+
app.add_middleware(
|
| 38 |
+
CORSMiddleware,
|
| 39 |
+
allow_origins=["*"], # Allows all origins
|
| 40 |
+
allow_credentials=True,
|
| 41 |
+
allow_methods=["*"], # Allows all methods
|
| 42 |
+
allow_headers=["*"], # Allows all headers
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
# --- Database Connection (using context manager for safety) ---
|
| 47 |
@contextmanager
|
| 48 |
def get_db_context():
|
|
|
|
| 63 |
if conn:
|
| 64 |
conn.close()
|
| 65 |
|
| 66 |
+
# --- Pydantic Models (keep existing) ---
|
| 67 |
class ColumnDefinition(BaseModel):
|
| 68 |
name: str
|
| 69 |
type: str
|
|
|
|
| 91 |
message: str
|
| 92 |
details: Optional[Any] = None
|
| 93 |
|
| 94 |
+
# --- Helper Functions (keep existing) ---
|
| 95 |
def safe_identifier(name: str) -> str:
|
| 96 |
"""Quotes an identifier safely using DuckDB."""
|
|
|
|
| 97 |
if not name or not isinstance(name, str):
|
| 98 |
raise HTTPException(status_code=400, detail=f"Invalid identifier provided: {name}")
|
|
|
|
| 99 |
try:
|
|
|
|
| 100 |
with duckdb.connect(':memory:') as temp_conn:
|
|
|
|
| 101 |
quoted = temp_conn.sql(f"SELECT '{name}'::IDENTIFIER").fetchone()
|
| 102 |
if quoted:
|
| 103 |
return quoted[0]
|
|
|
|
| 105 |
raise HTTPException(status_code=500, detail="Failed to quote identifier")
|
| 106 |
except duckdb.Error as e:
|
| 107 |
logger.error(f"Error quoting identifier '{name}': {e}")
|
|
|
|
| 108 |
raise HTTPException(status_code=400, detail=f"Invalid identifier '{name}': {e}")
|
| 109 |
|
| 110 |
def generate_column_sql(columns: List[ColumnDefinition]) -> str:
|
|
|
|
| 112 |
defs = []
|
| 113 |
for col in columns:
|
| 114 |
col_name_safe = safe_identifier(col.name)
|
|
|
|
| 115 |
allowed_types_prefix = ['INTEGER', 'VARCHAR', 'TEXT', 'BOOLEAN', 'FLOAT', 'DOUBLE', 'DATE', 'TIMESTAMP', 'BLOB', 'BIGINT', 'DECIMAL', 'LIST', 'STRUCT', 'MAP', 'UNION']
|
| 116 |
type_upper = col.type.strip().upper()
|
|
|
|
| 117 |
is_allowed = False
|
| 118 |
for prefix in allowed_types_prefix:
|
|
|
|
| 119 |
if type_upper.startswith(prefix):
|
| 120 |
is_allowed = True
|
| 121 |
break
|
|
|
|
| 122 |
if not is_allowed:
|
|
|
|
| 123 |
raise HTTPException(status_code=400, detail=f"Unsupported or potentially invalid data type: {col.type}")
|
| 124 |
+
defs.append(f"{col_name_safe} {col.type}")
|
|
|
|
| 125 |
return ", ".join(defs)
|
| 126 |
|
| 127 |
def result_to_dict(cursor_description, rows):
|
| 128 |
"""Converts cursor results (description + rows) to a list of dictionaries."""
|
| 129 |
+
if not cursor_description: # Handle cases like non-SELECT queries returning None description
|
| 130 |
+
return []
|
| 131 |
column_names = [desc[0] for desc in cursor_description]
|
| 132 |
return [dict(zip(column_names, row)) for row in rows]
|
| 133 |
|
|
|
|
| 134 |
|
| 135 |
+
# --- NEW ROOT ENDPOINT ---
|
| 136 |
+
@app.get("/", include_in_schema=False) # include_in_schema=False hides it from OpenAPI docs
|
| 137 |
+
async def read_index_html():
|
| 138 |
+
"""Serves the main index.html file."""
|
| 139 |
+
if not os.path.exists(HTML_FILE_PATH):
|
| 140 |
+
logger.error(f"{HTML_FILE_PATH} not found!")
|
| 141 |
+
raise HTTPException(status_code=404, detail="index.html not found")
|
| 142 |
+
logger.info(f"Serving {HTML_FILE_PATH}")
|
| 143 |
+
return FileResponse(HTML_FILE_PATH)
|
| 144 |
+
|
| 145 |
+
# --- API Endpoints (keep or adapt existing, add /tables and /tables/{...}/schema if not present) ---
|
| 146 |
|
|
|
|
| 147 |
@app.get("/tables", summary="List Tables", response_model=List[str])
|
| 148 |
async def list_tables():
|
| 149 |
"""Lists all tables in the default schema."""
|
| 150 |
try:
|
| 151 |
with get_db_context() as conn:
|
| 152 |
+
tables = conn.execute("SELECT table_name FROM information_schema.tables WHERE table_schema = 'main' ORDER BY table_name").fetchall()
|
|
|
|
| 153 |
return [table[0] for table in tables]
|
| 154 |
except duckdb.Error as e:
|
| 155 |
logger.error(f"Error listing tables: {e}")
|
| 156 |
raise HTTPException(status_code=500, detail=f"Error listing tables: {e}")
|
| 157 |
|
|
|
|
| 158 |
@app.get("/tables/{table_name}/schema", summary="Get Table Schema", response_model=TableSchemaResponse)
|
| 159 |
async def get_table_schema(
|
| 160 |
table_name: str = FastPath(..., description="Name of the table")
|
| 161 |
):
|
| 162 |
"""Gets the schema (column names and types) for a specific table."""
|
| 163 |
table_name_safe = safe_identifier(table_name)
|
|
|
|
| 164 |
sql = f"PRAGMA table_info({table_name_safe});"
|
| 165 |
try:
|
| 166 |
with get_db_context() as conn:
|
| 167 |
result = conn.execute(sql).fetchall()
|
| 168 |
if not result:
|
| 169 |
raise HTTPException(status_code=404, detail=f"Table '{table_name}' not found or has no columns.")
|
|
|
|
| 170 |
columns = [ColumnDefinition(name=row[1], type=row[2]) for row in result]
|
| 171 |
return TableSchemaResponse(columns=columns)
|
| 172 |
except duckdb.CatalogException as e:
|
|
|
|
| 175 |
logger.error(f"Error getting schema for table '{table_name}': {e}")
|
| 176 |
raise HTTPException(status_code=400, detail=f"Error getting table schema: {e}")
|
| 177 |
|
|
|
|
| 178 |
@app.post("/query", summary="Execute Read-Only SQL Query")
|
| 179 |
async def execute_query(query_request: SQLQueryRequest):
|
| 180 |
"""Executes a provided SQL query (read-only enforced)."""
|
| 181 |
sql = query_request.sql.strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
forbidden_keywords = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'ATTACH', 'DETACH', 'COPY', 'EXPORT', 'IMPORT']
|
| 183 |
sql_upper = sql.upper()
|
| 184 |
if any(keyword in sql_upper for keyword in forbidden_keywords):
|
| 185 |
raise HTTPException(status_code=403, detail="Only SELECT queries are allowed.")
|
| 186 |
+
if not sql_upper.startswith('SELECT') and not sql_upper.startswith('WITH') and not sql_upper.startswith('PRAGMA') and not sql_upper.startswith('SHOW'):
|
| 187 |
+
# Allow PRAGMA and SHOW for exploration
|
| 188 |
+
raise HTTPException(status_code=400, detail="Query must start with SELECT, WITH, PRAGMA, or SHOW.")
|
| 189 |
|
| 190 |
try:
|
| 191 |
logger.info(f"Executing user SQL: {sql}")
|
| 192 |
with get_db_context() as conn:
|
| 193 |
+
# Use sql() to get a relation, which gives description even for empty results
|
| 194 |
+
rel = conn.sql(sql)
|
| 195 |
+
description = rel.description
|
| 196 |
+
result = rel.fetchall()
|
| 197 |
data = result_to_dict(description, result)
|
| 198 |
return data
|
| 199 |
except duckdb.Error as e:
|
|
|
|
| 203 |
logger.error(f"Unexpected error executing user query: {e}")
|
| 204 |
raise HTTPException(status_code=500, detail="An unexpected error occurred during query execution.")
|
| 205 |
|
| 206 |
+
|
| 207 |
@app.post("/tables/{table_name}", summary="Create Table", response_model=ApiResponse, status_code=201)
|
| 208 |
async def create_table(
|
| 209 |
table_name: str = FastPath(..., description="Name of the table to create"),
|
| 210 |
schema: CreateTableRequest = ...,
|
| 211 |
):
|
| 212 |
+
"""Creates or replaces a table with the specified schema."""
|
| 213 |
table_name_safe = safe_identifier(table_name)
|
| 214 |
if not schema.columns:
|
| 215 |
raise HTTPException(status_code=400, detail="Table must have at least one column.")
|
|
|
|
| 216 |
try:
|
| 217 |
columns_sql = generate_column_sql(schema.columns)
|
| 218 |
+
sql = f"CREATE OR REPLACE TABLE {table_name_safe} ({columns_sql});"
|
| 219 |
logger.info(f"Executing SQL: {sql}")
|
| 220 |
with get_db_context() as conn:
|
| 221 |
conn.execute(sql)
|
| 222 |
return {"message": f"Table '{table_name}' created or replaced successfully."}
|
| 223 |
+
except HTTPException as e: raise e
|
|
|
|
| 224 |
except duckdb.Error as e:
|
| 225 |
+
logger.error(f"Error creating/replacing table '{table_name}': {e}")
|
| 226 |
+
raise HTTPException(status_code=400, detail=f"Error creating/replacing table: {e}")
|
| 227 |
except Exception as e:
|
| 228 |
+
logger.error(f"Unexpected error creating/replacing table '{table_name}': {e}")
|
| 229 |
raise HTTPException(status_code=500, detail="An unexpected error occurred.")
|
| 230 |
|
| 231 |
+
|
| 232 |
@app.get("/tables/{table_name}", summary="Read Table Data")
|
| 233 |
async def read_table(
|
| 234 |
table_name: str = FastPath(..., description="Name of the table to read from"),
|
| 235 |
+
limit: Optional[int] = 100,
|
| 236 |
+
offset: Optional[int] = 0
|
| 237 |
):
|
| 238 |
"""Reads and returns rows from a specified table. Supports limit and offset."""
|
| 239 |
table_name_safe = safe_identifier(table_name)
|
|
|
|
| 246 |
sql += " OFFSET ?"
|
| 247 |
params.append(offset)
|
| 248 |
sql += ";"
|
|
|
|
| 249 |
try:
|
| 250 |
logger.info(f"Executing SQL: {sql} with params: {params}")
|
| 251 |
with get_db_context() as conn:
|
| 252 |
+
rel = conn.sql(sql, params=params)
|
| 253 |
+
description = rel.description
|
| 254 |
+
result = rel.fetchall()
|
| 255 |
data = result_to_dict(description, result)
|
| 256 |
return data
|
| 257 |
except duckdb.CatalogException as e:
|
|
|
|
| 263 |
logger.error(f"Unexpected error reading table '{table_name}': {e}")
|
| 264 |
raise HTTPException(status_code=500, detail="An unexpected error occurred.")
|
| 265 |
|
| 266 |
+
# ... (keep other existing endpoints like create_rows, update_rows, delete_rows, downloads, health check) ...
|
| 267 |
+
# Make sure they use `with get_db_context() as conn:` instead of the `for conn in get_db():` loop
|
| 268 |
+
# For example:
|
| 269 |
|
| 270 |
@app.post("/tables/{table_name}/rows", summary="Create Rows", response_model=ApiResponse, status_code=201)
|
| 271 |
async def create_rows(
|
| 272 |
table_name: str = FastPath(..., description="Name of the table to insert into"),
|
| 273 |
request: CreateRowRequest = ...,
|
| 274 |
):
|
|
|
|
| 275 |
table_name_safe = safe_identifier(table_name)
|
| 276 |
if not request.rows:
|
| 277 |
raise HTTPException(status_code=400, detail="No rows provided to insert.")
|
|
|
|
|
|
|
| 278 |
columns = list(request.rows[0].keys())
|
| 279 |
columns_safe = [safe_identifier(col) for col in columns]
|
| 280 |
placeholders = ", ".join(["?"] * len(columns))
|
| 281 |
columns_sql = ", ".join(columns_safe)
|
|
|
|
| 282 |
sql = f"INSERT INTO {table_name_safe} ({columns_sql}) VALUES ({placeholders});"
|
|
|
|
|
|
|
| 283 |
params_list = []
|
| 284 |
for row_dict in request.rows:
|
| 285 |
if list(row_dict.keys()) != columns:
|
| 286 |
raise HTTPException(status_code=400, detail="All rows must have the same columns in the same order.")
|
| 287 |
params_list.append(list(row_dict.values()))
|
|
|
|
| 288 |
try:
|
| 289 |
logger.info(f"Executing SQL: {sql} for {len(params_list)} rows")
|
| 290 |
+
with get_db_context() as conn: # Use context manager
|
| 291 |
conn.executemany(sql, params_list)
|
|
|
|
| 292 |
return {"message": f"Successfully inserted {len(params_list)} rows into '{table_name}'."}
|
| 293 |
+
except duckdb.CatalogException:
|
| 294 |
raise HTTPException(status_code=404, detail=f"Table '{table_name}' not found.")
|
| 295 |
except duckdb.Error as e:
|
| 296 |
logger.error(f"Error inserting rows into '{table_name}': {e}")
|
|
|
|
| 299 |
logger.error(f"Unexpected error inserting rows into '{table_name}': {e}")
|
| 300 |
raise HTTPException(status_code=500, detail="An unexpected error occurred.")
|
| 301 |
|
| 302 |
+
# --- Apply the `with get_db_context() as conn:` pattern to update_rows, delete_rows, download_table_csv etc. ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
# --- Download Endpoints ---
|
| 305 |
@app.get("/download/table/{table_name}", summary="Download Table as CSV")
|
| 306 |
async def download_table_csv(
|
| 307 |
table_name: str = FastPath(..., description="Name of the table to download")
|
| 308 |
):
|
|
|
|
| 309 |
table_name_safe = safe_identifier(table_name)
|
| 310 |
sql = f"COPY (SELECT * FROM {table_name_safe}) TO STDOUT (FORMAT CSV, HEADER)"
|
| 311 |
|
| 312 |
async def stream_csv_data():
|
| 313 |
try:
|
|
|
|
| 314 |
with get_db_context() as conn:
|
| 315 |
# Check if table exists before fetching
|
| 316 |
conn.execute(f"SELECT 1 FROM {table_name_safe} LIMIT 0")
|
| 317 |
+
# Use pandas for CSV conversion in-memory
|
| 318 |
df = conn.execute(f"SELECT * FROM {table_name_safe}").df()
|
| 319 |
|
| 320 |
all_data_io = io.StringIO()
|
|
|
|
| 324 |
chunk_size = 8192
|
| 325 |
while True:
|
| 326 |
chunk = all_data_io.read(chunk_size)
|
| 327 |
+
if not chunk: break
|
| 328 |
+
yield chunk.encode('utf-8')
|
|
|
|
| 329 |
await asyncio.sleep(0)
|
| 330 |
all_data_io.close()
|
| 331 |
+
except duckdb.CatalogException:
|
|
|
|
| 332 |
yield f"Error: Table '{table_name}' not found.".encode('utf-8')
|
| 333 |
logger.error(f"Error downloading table '{table_name}': Table not found.")
|
| 334 |
except duckdb.Error as e:
|
|
|
|
| 347 |
|
| 348 |
@app.get("/download/database", summary="Download Database File")
|
| 349 |
async def download_database_file():
|
|
|
|
| 350 |
if not os.path.exists(DATABASE_PATH):
|
| 351 |
raise HTTPException(status_code=404, detail="Database file not found.")
|
| 352 |
logger.warning("Attempting to download database file. Ensure no active writes are occurring.")
|
| 353 |
return FileResponse(
|
| 354 |
path=DATABASE_PATH,
|
| 355 |
filename=os.path.basename(DATABASE_PATH),
|
| 356 |
+
media_type="application/vnd.duckdb.database"
|
| 357 |
)
|
| 358 |
|
|
|
|
| 359 |
# --- Health Check ---
|
| 360 |
@app.get("/health", summary="Health Check", response_model=ApiResponse)
|
| 361 |
async def health_check():
|
|
|
|
| 362 |
try:
|
| 363 |
with get_db_context() as conn:
|
| 364 |
conn.execute("SELECT 1")
|