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")
|