amaye15 commited on
Commit
3bcb6cf
·
1 Parent(s): c43b33b
Files changed (2) hide show
  1. index.html +161 -141
  2. 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: 10px 20px;
23
  display: flex;
24
  align-items: center;
25
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
 
 
26
  }
27
-
28
- header input[type="text"] {
29
- flex-grow: 1;
30
- margin-right: 10px;
31
- padding: 8px;
32
- border: 1px solid #ccc;
33
- border-radius: 4px;
34
- }
35
-
36
- header button {
37
- padding: 8px 15px;
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: 200px;
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 5px;
77
  cursor: pointer;
78
  border-radius: 4px;
79
  margin-bottom: 5px;
80
- transition: background-color 0.2s;
 
 
81
  }
82
 
83
- #tableList li:hover, #tableList li.active {
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
- #schemaDisplay, #dataDisplayContainer, #queryResultContainer {
103
- background-color: #fff;
104
- border: 1px solid #dee2e6;
105
- border-radius: 4px;
106
- padding: 15px;
107
- margin-bottom: 20px;
108
- overflow: auto; /* Allow scrolling within these areas */
 
 
 
 
 
 
 
 
109
  }
110
 
111
- #dataDisplayContainer {
112
- flex: 1; /* Takes remaining space */
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 #ccc;
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: #007bff;
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: #0056b3;
144
  }
145
 
146
  table {
147
  width: 100%;
148
  border-collapse: collapse;
149
- margin-top: 10px;
 
150
  }
151
 
152
  th, td {
153
- border: 1px solid #ddd;
154
- padding: 8px;
155
  text-align: left;
156
  white-space: nowrap;
 
 
 
157
  }
158
 
159
  th {
160
- background-color: #f2f2f2;
161
- font-weight: bold;
 
 
 
162
  }
163
 
164
  tr:nth-child(even) {
165
- background-color: #f9f9f9;
166
  }
167
 
168
- #statusMessage {
169
- padding: 10px;
170
- margin-top: 10px;
171
- border-radius: 4px;
172
- display: none; /* Hidden by default */
173
- }
 
174
 
175
  #statusMessage.success {
176
- background-color: #d4edda;
177
- color: #155724;
178
- border: 1px solid #c3e6cb;
179
  }
180
 
181
  #statusMessage.error {
182
  background-color: #f8d7da;
183
- color: #721c24;
184
- border: 1px solid #f5c6cb;
185
  }
186
- .loader {
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
- <label for="apiUrl" style="margin-right: 10px; font-weight: bold; color: white;">API URL:</label>
207
- <input type="text" id="apiUrl" value="http://localhost:8000" placeholder="Enter API Base URL">
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
- <!-- Table names will be loaded here -->
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 to view its schema.</p>
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 to view its data (limited rows).</p>
230
- <div id="dataDisplay" style="max-height: 300px; overflow-y: auto;">
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" style="max-height: 300px; overflow-y: auto;">
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
- const apiUrlInput = document.getElementById('apiUrl');
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
- let API_BASE_URL = '';
 
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 if response is not JSON */ }
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
- return await response.text(); // Or handle other types
 
312
  } catch (error) {
313
  console.error('API Fetch Error:', error);
314
  showStatus(`Error: ${error.message}`, true);
315
- throw error; // Re-throw to stop further processing
316
  } finally {
317
  showLoader(false);
318
  }
319
  }
320
 
321
  function renderTable(data, tableElement) {
322
- tableElement.innerHTML = ''; // Clear previous content
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
- // Handle null or undefined gracefully
344
- cell.textContent = rowData[header] === null || rowData[header] === undefined ? 'NULL' : String(rowData[header]);
345
- });
 
346
  });
347
  }
348
 
349
  function renderSchema(schemaData) {
350
- const tableElement = schemaTable;
351
- tableElement.innerHTML = ''; // Clear previous
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
- API_BASE_URL = apiUrlInput.value.trim().replace(/\/$/, ''); // Remove trailing slash
381
- if (!API_BASE_URL) {
382
- showStatus("API URL cannot be empty.", true);
383
- return;
384
- }
 
385
  try {
386
- // Optional: Ping root or health endpoint first
387
- // await fetchAPI('/');
388
  currentTables = await fetchAPI('/tables');
389
  displayTables(currentTables);
390
- showStatus("Connected. Tables loaded.", false);
391
- // Clear previous displays
392
- schemaDisplay.innerHTML = '<h4>Schema</h4><p>Select a table from the list.</p>';
393
- dataTable.innerHTML = '';
394
- tableDataHeader.textContent = '';
395
- queryResultContainer.style.display = 'none';
 
 
 
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'; // Hide query results
429
- dataDisplayContainer.style.display = 'flex'; // Show table data area
 
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
- const [schemaData, tableData] = await Promise.all([
 
437
  fetchAPI(`/tables/${selectedTable}/schema`),
438
- fetchAPI(`/tables/${selectedTable}?limit=100`) // Load first 100 rows
439
  ]);
440
- renderSchema(schemaData);
441
- renderTable(tableData, dataTable);
442
  } catch (error) {
443
- schemaTable.innerHTML = '<tbody><tr><td>Error loading schema.</td></tr></tbody>';
 
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
- if (!API_BASE_URL) {
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
- queryResultTable.innerHTML = '<tbody><tr><td>Error executing query.</td></tr></tbody>';
476
- // fetchAPI already shows the status
477
  }
478
  }
479
 
480
  // --- Initial Setup ---
481
- connectButton.onclick = loadTables;
482
  runSqlButton.onclick = runCustomQuery;
483
 
484
- // Optional: Load tables on page load if API URL is preset
485
- // if (apiUrlInput.value) {
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 # <--- Add contextlib
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
- @app.get("/", summary="API Root", response_model=ApiResponse)
131
- async def read_root():
132
- """Provides a welcome message for the API."""
133
- return {"message": "Welcome to the DuckDB API!"}
 
 
 
 
 
 
 
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
- # Show user tables (excluding system tables)
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
- raise HTTPException(status_code=400, detail="Query must start with SELECT or WITH.")
 
186
 
187
  try:
188
  logger.info(f"Executing user SQL: {sql}")
189
  with get_db_context() as conn:
190
- description = conn.execute(sql).description
191
- result = conn.fetchall()
192
- # Convert rows to dictionaries for JSON serialization
 
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
- # --- Existing Endpoints (Keep or adapt as needed) ---
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 new table with the specified schema."""
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});" # Use CREATE OR REPLACE for simplicity
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, # Default limit
233
- offset: Optional[int] = 0 # Default offset
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
- description = conn.execute(sql, params).description
251
- result = conn.fetchall()
 
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 as e:
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
- break
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" # More specific media type
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")