feat(dev_tools): enhance SQL Query Tool — clear, copy, history, edit, hardening
All checks were successful
CI / ruff (push) Successful in 15s
CI / pytest (push) Successful in 2h42m26s
CI / validate (push) Successful in 34s
CI / dependency-scanning (push) Successful in 34s
CI / docs (push) Successful in 54s
CI / deploy (push) Successful in 1m43s

UI: add Clear and Copy-to-clipboard (TSV) buttons, an in-page Recent
Queries pane (localStorage, capped at 20, de-duped) and a pencil-edit
flow for saved queries with a dedicated SQL field in the modal.
Bind Ctrl/Cmd+S to open the save modal (or edit the active saved query).

Backend: harden validate_query with a multi-statement guard that
respects string literals + comments. Stop swallowing record_query_run
errors silently — log via logger.exception so failures show up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 11:24:40 +02:00
parent 64a178f45d
commit e94b6d07bb
5 changed files with 268 additions and 18 deletions

View File

@@ -31,12 +31,19 @@ function sqlQueryTool() {
loadingSaved: false,
activeSavedId: null,
// Save modal
// Save / edit modal
showSaveModal: false,
saveName: '',
saveDescription: '',
saveSql: '',
editingSavedId: null,
saving: false,
// Ad-hoc query history (localStorage)
history: [],
historyKey: 'sqlQueryTool:history:v1',
historyMax: 20,
// Schema explorer
showPresets: true,
expandedCategories: {},
@@ -323,11 +330,25 @@ function sqlQueryTool() {
sqlLog.error('Failed to initialize:', e);
}
// Ctrl+Enter shortcut
this.loadHistoryFromStorage();
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
this.executeQuery();
} else if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
if (this.sql.trim()) {
e.preventDefault();
if (this.activeSavedId) {
const q = this.savedQueries.find(s => s.id === this.activeSavedId);
if (q) {
this.openEditModal(q);
return;
}
}
this.openSaveModal();
}
}
});
},
@@ -342,8 +363,9 @@ function sqlQueryTool() {
this.truncated = false;
this.executionTimeMs = null;
const submittedSql = this.sql;
try {
const payload = { sql: this.sql };
const payload = { sql: submittedSql };
if (this.activeSavedId) {
payload.saved_query_id = this.activeSavedId;
}
@@ -354,17 +376,65 @@ function sqlQueryTool() {
this.truncated = data.truncated;
this.executionTimeMs = data.execution_time_ms;
this.pushHistory({
sql: submittedSql,
ts: Date.now(),
row_count: data.row_count,
elapsed: data.execution_time_ms,
});
// Refresh saved queries to update run_count
if (this.activeSavedId) {
await this.loadSavedQueries();
}
} catch (e) {
this.error = e.message;
this.pushHistory({ sql: submittedSql, ts: Date.now(), error: true });
} finally {
this.running = false;
}
},
clearQuery() {
this.sql = '';
this.columns = [];
this.rows = [];
this.rowCount = 0;
this.truncated = false;
this.executionTimeMs = null;
this.error = null;
this.activeSavedId = null;
},
async copyResults() {
if (!this.columns.length || !this.rows.length) return;
const escape = (val) => {
if (val === null || val === undefined) return '';
const s = typeof val === 'object' ? JSON.stringify(val) : String(val);
// TSV: replace tabs/newlines so cells stay one-line in spreadsheets
return s.replace(/\t/g, ' ').replace(/\r?\n/g, ' ');
};
const lines = [this.columns.join('\t')];
for (const row of this.rows) {
lines.push(row.map(escape).join('\t'));
}
const text = lines.join('\n');
try {
await navigator.clipboard.writeText(text);
if (typeof Utils !== 'undefined' && Utils.showToast) {
Utils.showToast(`Copied ${this.rows.length} row${this.rows.length === 1 ? '' : 's'} to clipboard`, 'success');
}
} catch (e) {
sqlLog.error('Failed to copy results:', e);
if (typeof Utils !== 'undefined' && Utils.showToast) {
Utils.showToast('Failed to copy results', 'error');
}
}
},
async loadSavedQueries() {
this.loadingSaved = true;
try {
@@ -389,23 +459,57 @@ function sqlQueryTool() {
},
openSaveModal() {
this.editingSavedId = null;
this.saveName = '';
this.saveDescription = '';
this.saveSql = '';
this.showSaveModal = true;
},
openEditModal(q) {
this.editingSavedId = q.id;
this.saveName = q.name || '';
this.saveDescription = q.description || '';
this.saveSql = q.sql_text || '';
this.showSaveModal = true;
},
async saveQuery() {
if (!this.saveName.trim() || !this.sql.trim()) return;
if (!this.saveName.trim()) return;
this.saving = true;
try {
const saved = await apiClient.post('/admin/sql-query/saved', {
name: this.saveName,
sql_text: this.sql,
description: this.saveDescription || null,
});
this.activeSavedId = saved.id;
let saved;
if (this.editingSavedId) {
if (!this.saveSql.trim()) {
this.saving = false;
return;
}
saved = await apiClient.put(`/admin/sql-query/saved/${this.editingSavedId}`, {
name: this.saveName,
sql_text: this.saveSql,
description: this.saveDescription || null,
});
if (this.activeSavedId === this.editingSavedId) {
this.sql = saved.sql_text;
}
} else {
if (!this.sql.trim()) {
this.saving = false;
return;
}
saved = await apiClient.post('/admin/sql-query/saved', {
name: this.saveName,
sql_text: this.sql,
description: this.saveDescription || null,
});
this.activeSavedId = saved.id;
}
this.showSaveModal = false;
this.editingSavedId = null;
await this.loadSavedQueries();
if (typeof Utils !== 'undefined' && Utils.showToast) {
Utils.showToast('Saved', 'success');
}
} catch (e) {
this.error = e.message;
} finally {
@@ -461,5 +565,61 @@ function sqlQueryTool() {
isNull(val) {
return val === null || val === undefined;
},
// ── History (localStorage) ─────────────────────────────────────────────
loadHistoryFromStorage() {
try {
const raw = localStorage.getItem(this.historyKey);
this.history = raw ? JSON.parse(raw) : [];
} catch (e) {
sqlLog.error('Failed to load history:', e);
this.history = [];
}
},
persistHistory() {
try {
localStorage.setItem(this.historyKey, JSON.stringify(this.history));
} catch (e) {
sqlLog.error('Failed to persist history:', e);
}
},
pushHistory(entry) {
const sql = (entry.sql || '').trim();
if (!sql) return;
const preview = sql.replace(/\s+/g, ' ').slice(0, 80);
// De-dupe against the most recent identical SQL
if (this.history.length > 0 && this.history[0].sql === sql) {
this.history.shift();
}
this.history.unshift({ ...entry, sql, preview });
if (this.history.length > this.historyMax) {
this.history.length = this.historyMax;
}
this.persistHistory();
},
loadHistory(h) {
this.sql = h.sql;
this.activeSavedId = null;
this.error = null;
},
clearHistory() {
this.history = [];
this.persistHistory();
},
formatHistoryTime(ts) {
if (!ts) return '';
const d = new Date(ts);
const today = new Date();
const sameDay = d.toDateString() === today.toDateString();
if (sameDay) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
},
};
}