feat(dev_tools): enhance SQL Query Tool — clear, copy, history, edit, hardening
All checks were successful
All checks were successful
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:
@@ -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' });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user