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:
@@ -67,7 +67,10 @@ async def execute_sql(
|
|||||||
try:
|
try:
|
||||||
record_query_run(db, body.saved_query_id)
|
record_query_run(db, body.saved_query_id)
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception:
|
except Exception: # noqa: EXC003
|
||||||
|
logger.exception(
|
||||||
|
"Failed to record run for saved query %s", body.saved_query_id
|
||||||
|
)
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ def _strip_sql_comments(sql: str) -> str:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_string_literals(sql: str) -> str:
|
||||||
|
"""Replace single-quoted string literals with a placeholder so their contents
|
||||||
|
don't trip the multi-statement check."""
|
||||||
|
return re.sub(r"'(?:''|[^'])*'", "''", sql)
|
||||||
|
|
||||||
|
|
||||||
def validate_query(sql: str) -> None:
|
def validate_query(sql: str) -> None:
|
||||||
"""Validate that the SQL query is SELECT-only (no DML/DDL)."""
|
"""Validate that the SQL query is SELECT-only (no DML/DDL)."""
|
||||||
stripped = sql.strip().rstrip(";")
|
stripped = sql.strip().rstrip(";")
|
||||||
@@ -60,6 +66,14 @@ def validate_query(sql: str) -> None:
|
|||||||
f"Forbidden SQL keyword: {match.group().upper()}. Only SELECT queries are allowed."
|
f"Forbidden SQL keyword: {match.group().upper()}. Only SELECT queries are allowed."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Defense-in-depth: reject queries that chain a second statement after `;`.
|
||||||
|
# SQLAlchemy's `text()` over psycopg generally sends a single statement, but
|
||||||
|
# this guards against future driver changes and makes intent explicit.
|
||||||
|
if ";" in _strip_string_literals(code_only):
|
||||||
|
raise QueryValidationError(
|
||||||
|
"Multiple statements are not allowed. Submit one SELECT at a time."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_json_safe(value: Any) -> Any:
|
def _make_json_safe(value: Any) -> Any:
|
||||||
"""Convert a database value to a JSON-serializable representation."""
|
"""Convert a database value to a JSON-serializable representation."""
|
||||||
|
|||||||
@@ -31,12 +31,19 @@ function sqlQueryTool() {
|
|||||||
loadingSaved: false,
|
loadingSaved: false,
|
||||||
activeSavedId: null,
|
activeSavedId: null,
|
||||||
|
|
||||||
// Save modal
|
// Save / edit modal
|
||||||
showSaveModal: false,
|
showSaveModal: false,
|
||||||
saveName: '',
|
saveName: '',
|
||||||
saveDescription: '',
|
saveDescription: '',
|
||||||
|
saveSql: '',
|
||||||
|
editingSavedId: null,
|
||||||
saving: false,
|
saving: false,
|
||||||
|
|
||||||
|
// Ad-hoc query history (localStorage)
|
||||||
|
history: [],
|
||||||
|
historyKey: 'sqlQueryTool:history:v1',
|
||||||
|
historyMax: 20,
|
||||||
|
|
||||||
// Schema explorer
|
// Schema explorer
|
||||||
showPresets: true,
|
showPresets: true,
|
||||||
expandedCategories: {},
|
expandedCategories: {},
|
||||||
@@ -323,11 +330,25 @@ function sqlQueryTool() {
|
|||||||
sqlLog.error('Failed to initialize:', e);
|
sqlLog.error('Failed to initialize:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+Enter shortcut
|
this.loadHistoryFromStorage();
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.executeQuery();
|
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.truncated = false;
|
||||||
this.executionTimeMs = null;
|
this.executionTimeMs = null;
|
||||||
|
|
||||||
|
const submittedSql = this.sql;
|
||||||
try {
|
try {
|
||||||
const payload = { sql: this.sql };
|
const payload = { sql: submittedSql };
|
||||||
if (this.activeSavedId) {
|
if (this.activeSavedId) {
|
||||||
payload.saved_query_id = this.activeSavedId;
|
payload.saved_query_id = this.activeSavedId;
|
||||||
}
|
}
|
||||||
@@ -354,17 +376,65 @@ function sqlQueryTool() {
|
|||||||
this.truncated = data.truncated;
|
this.truncated = data.truncated;
|
||||||
this.executionTimeMs = data.execution_time_ms;
|
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
|
// Refresh saved queries to update run_count
|
||||||
if (this.activeSavedId) {
|
if (this.activeSavedId) {
|
||||||
await this.loadSavedQueries();
|
await this.loadSavedQueries();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = e.message;
|
this.error = e.message;
|
||||||
|
this.pushHistory({ sql: submittedSql, ts: Date.now(), error: true });
|
||||||
} finally {
|
} finally {
|
||||||
this.running = false;
|
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() {
|
async loadSavedQueries() {
|
||||||
this.loadingSaved = true;
|
this.loadingSaved = true;
|
||||||
try {
|
try {
|
||||||
@@ -389,23 +459,57 @@ function sqlQueryTool() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
openSaveModal() {
|
openSaveModal() {
|
||||||
|
this.editingSavedId = null;
|
||||||
this.saveName = '';
|
this.saveName = '';
|
||||||
this.saveDescription = '';
|
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;
|
this.showSaveModal = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveQuery() {
|
async saveQuery() {
|
||||||
if (!this.saveName.trim() || !this.sql.trim()) return;
|
if (!this.saveName.trim()) return;
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
try {
|
try {
|
||||||
const saved = await apiClient.post('/admin/sql-query/saved', {
|
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,
|
name: this.saveName,
|
||||||
sql_text: this.sql,
|
sql_text: this.sql,
|
||||||
description: this.saveDescription || null,
|
description: this.saveDescription || null,
|
||||||
});
|
});
|
||||||
this.activeSavedId = saved.id;
|
this.activeSavedId = saved.id;
|
||||||
|
}
|
||||||
this.showSaveModal = false;
|
this.showSaveModal = false;
|
||||||
|
this.editingSavedId = null;
|
||||||
await this.loadSavedQueries();
|
await this.loadSavedQueries();
|
||||||
|
if (typeof Utils !== 'undefined' && Utils.showToast) {
|
||||||
|
Utils.showToast('Saved', 'success');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = e.message;
|
this.error = e.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -461,5 +565,61 @@ function sqlQueryTool() {
|
|||||||
isNull(val) {
|
isNull(val) {
|
||||||
return val === null || val === undefined;
|
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' });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,11 +79,47 @@
|
|||||||
Run <span x-text="q.run_count"></span> time<span x-show="q.run_count !== 1">s</span>
|
Run <span x-text="q.run_count"></span> time<span x-show="q.run_count !== 1">s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button @click.stop="openEditModal(q)"
|
||||||
|
class="p-1 text-gray-400 hover:text-indigo-500"
|
||||||
|
title="Edit">
|
||||||
|
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
|
||||||
|
</button>
|
||||||
<button @click.stop="deleteSavedQuery(q.id)"
|
<button @click.stop="deleteSavedQuery(q.id)"
|
||||||
class="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-500 transition-opacity"
|
class="p-1 text-gray-400 hover:text-red-500"
|
||||||
title="Delete">
|
title="Delete">
|
||||||
<span x-html="$icon('trash', 'w-4 h-4')"></span>
|
<span x-html="$icon('trash', 'w-4 h-4')"></span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Queries (history) -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider flex items-center gap-1.5">
|
||||||
|
<span x-html="$icon('clock', 'w-4 h-4')"></span>
|
||||||
|
Recent
|
||||||
|
</h3>
|
||||||
|
<button @click="clearHistory()"
|
||||||
|
x-show="history.length > 0"
|
||||||
|
class="text-xs text-gray-400 hover:text-red-500"
|
||||||
|
title="Clear history">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div x-show="history.length === 0" class="text-sm text-gray-400">No recent queries.</div>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<template x-for="(h, idx) in history" :key="h.ts">
|
||||||
|
<li @click="loadHistory(h)"
|
||||||
|
class="rounded-md px-2 py-1.5 text-xs cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-400"
|
||||||
|
:title="h.sql">
|
||||||
|
<div class="truncate font-mono" x-text="h.preview"></div>
|
||||||
|
<div class="text-[10px] text-gray-400 mt-0.5">
|
||||||
|
<span x-text="formatHistoryTime(h.ts)"></span>
|
||||||
|
<span x-show="h.row_count !== undefined"> · <span x-text="h.row_count"></span> row<span x-show="h.row_count !== 1">s</span></span>
|
||||||
|
<span x-show="h.elapsed !== undefined"> · <span x-text="h.elapsed"></span>ms</span>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -128,6 +164,22 @@
|
|||||||
<span x-html="$icon('download', 'w-4 h-4 mr-1.5')"></span>
|
<span x-html="$icon('download', 'w-4 h-4 mr-1.5')"></span>
|
||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button @click="copyResults()"
|
||||||
|
x-show="rows.length > 0"
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 text-sm font-medium rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
title="Copy results as TSV (paste into spreadsheet)">
|
||||||
|
<span x-html="$icon('clipboard', 'w-4 h-4 mr-1.5')"></span>
|
||||||
|
Copy Results
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button @click="clearQuery()"
|
||||||
|
:disabled="!sql && columns.length === 0 && !error"
|
||||||
|
class="ml-auto inline-flex items-center px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 text-sm font-medium rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
title="Clear editor and results">
|
||||||
|
<span x-html="$icon('x-circle', 'w-4 h-4 mr-1.5')"></span>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -182,9 +234,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Save Query Modal -->
|
<!-- Save / Edit Query Modal -->
|
||||||
{% call modal('saveQueryModal', 'Save Query', show_var='showSaveModal', size='sm', show_footer=false) %}
|
{% call modal('saveQueryModal', 'Save Query', show_var='showSaveModal', size='sm', show_footer=false) %}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
<div class="text-xs uppercase tracking-wider text-gray-400" x-text="editingSavedId ? 'Edit saved query' : 'New saved query'"></div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
|
||||||
<input type="text" x-model="saveName"
|
<input type="text" x-model="saveName"
|
||||||
@@ -198,6 +251,13 @@
|
|||||||
class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 text-sm focus:ring-indigo-500 focus:border-indigo-500"
|
class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 text-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
placeholder="Brief description of what this query does">
|
placeholder="Brief description of what this query does">
|
||||||
</div>
|
</div>
|
||||||
|
<div x-show="editingSavedId">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SQL</label>
|
||||||
|
<textarea x-model="saveSql"
|
||||||
|
rows="6"
|
||||||
|
spellcheck="false"
|
||||||
|
class="w-full bg-gray-900 text-green-400 font-mono text-xs rounded-lg p-3 border border-gray-700 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 resize-y"></textarea>
|
||||||
|
</div>
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
<button @click="showSaveModal = false"
|
<button @click="showSaveModal = false"
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||||
@@ -206,7 +266,7 @@
|
|||||||
<button @click="saveQuery()"
|
<button @click="saveQuery()"
|
||||||
:disabled="!saveName.trim() || saving"
|
:disabled="!saveName.trim() || saving"
|
||||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors">
|
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors">
|
||||||
<span x-text="saving ? 'Saving...' : 'Save'"></span>
|
<span x-text="saving ? 'Saving...' : (editingSavedId ? 'Save changes' : 'Save')"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,3 +53,16 @@ class TestValidateQuery:
|
|||||||
|
|
||||||
def test_select_with_where(self):
|
def test_select_with_where(self):
|
||||||
validate_query("SELECT id, email FROM users WHERE is_active = true")
|
validate_query("SELECT id, email FROM users WHERE is_active = true")
|
||||||
|
|
||||||
|
def test_trailing_semicolon_allowed(self):
|
||||||
|
validate_query("SELECT 1;")
|
||||||
|
|
||||||
|
def test_multi_statement_rejected(self):
|
||||||
|
with pytest.raises(QueryValidationError, match="Multiple statements"):
|
||||||
|
validate_query("SELECT 1; SELECT 2")
|
||||||
|
|
||||||
|
def test_semicolon_in_string_literal_allowed(self):
|
||||||
|
validate_query("SELECT ';' AS sep")
|
||||||
|
|
||||||
|
def test_semicolon_in_comment_allowed(self):
|
||||||
|
validate_query("SELECT 1 -- end; second\nFROM dual")
|
||||||
|
|||||||
Reference in New Issue
Block a user