fix(mysql): prevalidate routine saves and support procedure refresh

This commit is contained in:
Mo-Moore
2026-09-22 13:03:32 +08:00
committed by GitHub
parent 0023c03520
commit 4ac2c5dc40
16 changed files with 173 additions and 20 deletions
@@ -1035,6 +1035,10 @@ function refreshData(): boolean {
void pluginFilesystemTabRef.value?.refresh();
return true;
}
if (props.activeTab.objectSource || props.activeTab.sourceLoad) {
if (queryStore.isTabDirty(props.activeTab) && !window.confirm(t("objects.refreshDiscardConfirm"))) return false;
return queryStore.refreshObjectSourceTab(props.activeTab.id);
}
// Restored data tabs intentionally omit row data, so refresh must work before DataGrid mounts.
if (canReloadUnavailableDataTab(props.activeTab)) {
reloadUnavailableDataTab();
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import { Play, CirclePlay, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2, SpellCheck2, Layers, MoreHorizontal, BetweenVerticalStart, Eye, WrapText } from "@lucide/vue";
import { Play, CirclePlay, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2, SpellCheck2, Layers, MoreHorizontal, BetweenVerticalStart, Eye, WrapText, RefreshCw } from "@lucide/vue";
import { supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
import { Button } from "@/components/ui/button";
import { SearchableSelect } from "@/components/ui/searchable-select";
@@ -12,6 +12,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { catalogDatabaseOptionsKey, databaseAfterCatalogChange, normalizedQueryTabCatalog, queryCatalogSelectorVisible, selectedQueryCatalogName, useDatabaseOptions } from "@/composables/useDatabaseOptions";
import { useSchemaOptions } from "@/composables/useSchemaOptions";
@@ -92,6 +93,7 @@ const emit = defineEmits<{
const { t } = useI18n();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const { databaseOptions, loadingDatabaseOptions, loadDatabaseOptions, catalogOptions, loadingCatalogOptions, loadCatalogOptions, catalogDatabaseOptions, loadingCatalogDatabaseOptions, loadCatalogDatabaseOptions } = useDatabaseOptions();
const { loadSchemaOptions, getSchemaOptionsForDb, isLoadingSchemas, isSchemaAware } = useSchemaOptions();
@@ -244,6 +246,14 @@ const saveTooltip = computed(() => {
if (props.activeTab.externalSqlPath) return t("toolbar.saveSqlFile");
return t("toolbar.saveSql");
});
const isObjectSourceTab = computed(() => !!props.activeTab.objectSource || !!props.activeTab.sourceLoad);
const objectSourceRefreshing = computed(() => !!props.activeTab.sourceLoad && !props.activeTab.sourceLoad.error);
function refreshObjectSource() {
if (!isObjectSourceTab.value) return;
if (queryStore.isTabDirty(props.activeTab) && !window.confirm(t("objects.refreshDiscardConfirm"))) return;
queryStore.refreshObjectSourceTab(props.activeTab.id);
}
const executeShortcutDisplay = computed(() => formatShortcutDisplay(settingsStore.editorSettings.shortcuts.executeSql));
const executeShortcutTooltip = computed(() => t("toolbar.executeShortcut", { shortcut: executeShortcutDisplay.value }));
// executableSql 在无选区时可能是整篇文档;只要有 DML 语句出现就显示预览按钮,
@@ -606,6 +616,22 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ saveTooltip }}</TooltipContent>
</Tooltip>
<Tooltip v-if="isObjectSourceTab">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-6 w-6 text-emerald-600 hover:bg-emerald-500/10 hover:text-emerald-700 dark:text-emerald-300 dark:hover:text-emerald-200"
:disabled="objectSourceRefreshing || activeTab.isExecuting"
:aria-label="t('structureEditor.refresh')"
@click="refreshObjectSource"
>
<Loader2 v-if="objectSourceRefreshing" class="h-3.5 w-3.5 animate-spin" />
<RefreshCw v-else class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("structureEditor.refresh") }}</TooltipContent>
</Tooltip>
<Tooltip v-if="showOpenSqlButton">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-sky-600 hover:bg-sky-500/10 hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-200" @click="emit('openSql')">
@@ -1385,6 +1385,10 @@ async function fetchTableConstraints(force = false) {
}
async function refreshActiveTableInfo() {
if (sidePanelMode.value === "source") {
await refreshActiveSource();
return;
}
if (sidePanelMode.value !== "table-info" || !sidePanelRow.value) return;
sidePanelGuard.bump();
@@ -1502,9 +1506,14 @@ async function openSource(row: ObjectBrowserRow) {
closeSidePanel();
return;
}
await loadSourcePanel(row);
}
async function loadSourcePanel(row: ObjectBrowserRow, options?: { preserveEditing?: boolean }) {
// Starting a different object must invalidate slower source requests before
// any state is reset, otherwise an old response can populate the new row.
const epoch = sidePanelGuard.start();
const preserveEditing = options?.preserveEditing === true && sourceEditing.value;
sidePanelRow.value = row;
sidePanelMode.value = "source";
sourceRow.value = row;
@@ -1538,7 +1547,9 @@ async function openSource(row: ObjectBrowserRow) {
sourceEditableText.value = editable;
sourceContent.value = row.type === "SEQUENCE" ? result.source : editable;
sourceDraft.value = editable;
sourceEditing.value = sourceCanEdit.value && row.type !== "SEQUENCE";
// Fresh open always enters edit when allowed; refresh preserves prior edit mode.
const enterEditing = sourceCanEdit.value && row.type !== "SEQUENCE" && (options?.preserveEditing ? preserveEditing : true);
sourceEditing.value = enterEditing;
if (!sourceCanEdit.value && row.type !== "SEQUENCE") {
toast(t("objects.sourceReadOnly"), 3000);
}
@@ -1550,6 +1561,13 @@ async function openSource(row: ObjectBrowserRow) {
}
}
async function refreshActiveSource() {
const row = sourceRow.value || (sidePanelMode.value === "source" ? sidePanelRow.value : null);
if (!row || sidePanelMode.value !== "source") return;
if (sourceEditing.value && !window.confirm(t("objects.refreshDiscardConfirm"))) return;
await loadSourcePanel(row, { preserveEditing: true });
}
function openEventEditor(row: ObjectBrowserRow) {
sidePanelGuard.start();
sidePanelRow.value = row;
@@ -4039,6 +4057,9 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<Button v-if="!sourceEditing && sourceCanEdit" variant="ghost" size="icon" class="h-5 w-5" :disabled="!sourceContent" @click="editSource">
<PencilLine class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="sourceLoading || sourceSaving" :title="t('structureEditor.refresh')" :aria-label="t('structureEditor.refresh')" @click="refreshActiveSource">
<RefreshCw class="h-3 w-3" :class="{ 'animate-spin': sourceLoading }" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="closeSource">
<X class="h-3 w-3" />
</Button>
@@ -156,6 +156,12 @@ function cancelEditSource() {
saveError.value = "";
}
async function refreshSource() {
if (loading.value || saving.value) return;
if (editing.value && !window.confirm(t("objects.refreshDiscardConfirm"))) return;
await loadSource(editing.value && canEdit.value);
}
async function saveSource() {
if (saving.value || loading.value || !props.open) return;
if (!canEdit.value) {
@@ -271,6 +277,10 @@ function closeDialog() {
<DialogFooter>
<Button variant="outline" @click="closeDialog">{{ t("common.close") }}</Button>
<Button variant="outline" :disabled="loading || saving" :title="t('structureEditor.refresh')" @click="refreshSource">
<RefreshCw class="h-4 w-4" />
{{ t("structureEditor.refresh") }}
</Button>
<Button v-if="!editing" variant="outline" :disabled="!content" @click="copySource">
<Clipboard class="h-4 w-4" />
{{ t("grid.copy") }}
@@ -1395,7 +1395,7 @@ function requestRefreshSelectedNode(): boolean {
function canRefreshTreeNodeShortcut(): boolean {
const type = activeNode.value.type;
if (type === "connection" || type === "database" || type === "schema" || type === "table" || type === "view") {
if (type === "connection" || type === "database" || type === "schema" || type === "table" || type === "view" || type === "procedure" || type === "function") {
return true;
}
return isGroupLabel(activeNode.value) && type !== "group-partitions";
@@ -6394,6 +6394,12 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
}
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.changeOpenMode"), action: () => emit("open-settings", "navigation"), icon: Settings2 });
items.push({
label: t("contextMenu.refreshChildren"),
action: refresh,
icon: RefreshCw,
shortcut: shortcutRefresh,
});
if (!isPackageMember) {
items.push({ label: "", separator: true });
items.push({
+1
View File
@@ -4690,6 +4690,7 @@ export default {
cancelEdit: "Cancel",
sourceSaved: "Source saved",
sourceSaveFailed: "Failed to save source: {message}",
refreshDiscardConfirm: "Refreshing will discard unsaved source edits. Continue?",
postgresViewColumnChangeHint:
"PostgreSQL cannot use CREATE OR REPLACE VIEW to remove, reorder, rename, or change the type of existing view columns. Keep existing column count, order, names, and types unchanged; new columns can only be appended. Use ALTER VIEW ... RENAME COLUMN to rename a column. To remove a column, review dependencies and recreate the view manually.",
sourceReadOnly: "This source is read-only and cannot be edited.",
+1
View File
@@ -4262,6 +4262,7 @@ export default withEnglishFallback({
validity: "validez",
valid: "válido",
invalid: "inválido",
refreshDiscardConfirm: "Actualizar descartará los cambios sin guardar en el código fuente, ¿deseas continuar?",
},
structureEditor: {
mysqlDataTypeHelp: {
+1
View File
@@ -4259,6 +4259,7 @@ export default withEnglishFallback({
validity: "validità",
valid: "valido",
invalid: "non valido",
refreshDiscardConfirm: "L'aggiornamento eliminerà le modifiche non salvate al codice sorgente. Continuare?",
},
structureEditor: {
mysqlDataTypeHelp: {
+1
View File
@@ -4320,6 +4320,7 @@ export default withEnglishFallback({
validity: "有効性",
valid: "有効",
invalid: "無効",
refreshDiscardConfirm: "更新すると、保存されていないソースコードの変更が破棄されます。続行しますか?",
},
structureEditor: {
mysqlDataTypeHelp: {
+1
View File
@@ -4247,6 +4247,7 @@ export default withEnglishFallback({
sortAsc: "오름차순",
sortDesc: "내림차순",
sortBy: "정렬 기준",
refreshDiscardConfirm: "새로 고침하면 저장되지 않은 소스 코드 변경 사항이 삭제됩니다. 계속하시겠습니까?",
},
structureEditor: {
title: "테이블 구조 편집",
+1
View File
@@ -4261,6 +4261,7 @@ export default withEnglishFallback({
validity: "Validade",
valid: "Válido",
invalid: "Inválido",
refreshDiscardConfirm: "Atualizar descartará as modificações não salvas no código-fonte. Deseja continuar?",
},
structureEditor: {
mysqlDataTypeHelp: {
+1
View File
@@ -4665,6 +4665,7 @@ export default withEnglishFallback({
cancelEdit: "取消",
sourceSaved: "源码已保存",
sourceSaveFailed: "保存源码失败:{message}",
refreshDiscardConfirm: "刷新将丢弃未保存的源码修改,是否继续?",
postgresViewColumnChangeHint: "PostgreSQL 无法通过 CREATE OR REPLACE VIEW 删除、重排、重命名已有视图字段,或修改字段类型。请保持原字段的数量、顺序、名称和类型不变;新增字段只能追加在末尾。重命名字段请使用 ALTER VIEW ... RENAME COLUMN;删除字段前请检查依赖并手动重建视图。",
sourceReadOnly: "该源码为只读,不能编辑。",
schemaColumn: "Schema",
+1
View File
@@ -4000,6 +4000,7 @@ export default withEnglishFallback({
validity: "有效性",
valid: "有效",
invalid: "無效",
refreshDiscardConfirm: "重新整理將捨棄未儲存的原始碼修改,是否繼續?",
},
structureEditor: {
mysqlDataTypeHelp: {
+13 -1
View File
@@ -2346,7 +2346,19 @@ export const useConnectionStore = defineStore("connection", () => {
function invalidateMetadataCachesForNode(node: TreeNode, options?: { skipObjectCacheInvalidation?: boolean }) {
if (!node.connectionId) return;
const tableName = node.tableName || (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "dynamodb-table" ? node.label : undefined);
const isObjectLeaf =
node.type === "procedure" ||
node.type === "function" ||
node.type === "sequence" ||
node.type === "synonym" ||
node.type === "event" ||
node.type === "job" ||
node.type === "package" ||
node.type === "package-body" ||
node.type === "type" ||
node.type === "type-body" ||
node.type === "trigger";
const tableName = isObjectLeaf ? node.objectName || node.label : node.tableName || (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "dynamodb-table" ? node.label : undefined);
const match = {
connectionId: node.connectionId,
database: node.database || undefined,
+21
View File
@@ -2873,6 +2873,26 @@ export const useQueryStore = defineStore("query", () => {
void loadObjectSourceIntoTab(id);
}
/**
* Force-reload an object-source tab from the database (user-triggered refresh).
* Callers should confirm before discarding dirty edits.
*/
function refreshObjectSourceTab(id: string): boolean {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab) return false;
if (tab.sourceLoad && !tab.sourceLoad.error) return true;
const request = tab.sourceLoad?.request ? { ...tab.sourceLoad.request } : tab.objectSource ? { name: tab.objectSource.name, objectType: tab.objectSource.objectType, signature: tab.objectSource.signature } : null;
if (!request) return false;
sourceRevalidateInFlight.delete(id);
tab.sourceLoad = {
startedAt: Date.now(),
initialEditing: tab.sourceLoad?.initialEditing ?? !!tab.objectSource,
request,
};
void loadObjectSourceIntoTab(id);
return true;
}
function clearObjectSourceLoad(tab: QueryTab) {
tab.sourceLoad = undefined;
}
@@ -9006,6 +9026,7 @@ export const useQueryStore = defineStore("query", () => {
openObjectSourceTab,
openObjectSourceTabPending,
retryObjectSourceTab,
refreshObjectSourceTab,
showExecutedQueryResults,
focusGroup,
activateTab,
+61 -16
View File
@@ -701,14 +701,32 @@ fn executable_mysql_routine_statements(input: &EditableObjectSourceSqlInput, sou
let declaration = mysql_routine_declaration(source).filter(|declaration| declaration.kind == input.object_type);
let create_name = declaration.as_ref().map(|declaration| declaration.name.as_str()).unwrap_or(&input.name);
let mut statements = Vec::with_capacity(3);
let is_rename =
declaration.as_ref().is_some_and(|declaration| routine_name_changed(&declaration.name, &input.name));
let mut statements = Vec::with_capacity(6);
// MySQL has no cross-version CREATE OR REPLACE for stored routines. Validate the CREATE
// body under a temporary name first (same idea as Informix view saves) so a syntax error
// cannot leave the original routine deleted after DROP.
let validation_name = mysql_validation_routine_name(create_name);
if let Some(validation_source) = replace_mysql_routine_declaration_name(source, &validation_name) {
statements.push(mysql_drop_routine_if_exists(
input.object_type.clone(),
input.schema.as_deref(),
&validation_name,
));
statements.push(ensure_semicolon(&validation_source));
statements.push(mysql_drop_routine_if_exists(
input.object_type.clone(),
input.schema.as_deref(),
&validation_name,
));
}
// MySQL has no cross-version CREATE OR REPLACE for stored routines; DBeaver also
// replaces them by dropping the target routine before executing the CREATE body.
statements.push(mysql_drop_routine_if_exists(input.object_type.clone(), input.schema.as_deref(), create_name));
statements.push(ensure_semicolon(source));
if declaration.as_ref().is_some_and(|declaration| routine_name_changed(&declaration.name, &input.name)) {
if is_rename {
statements.push(mysql_drop_routine_if_exists(input.object_type.clone(), input.schema.as_deref(), &input.name));
}
@@ -723,6 +741,15 @@ fn mysql_source_starts_with_create_routine(source: &str) -> bool {
Regex::new(r"(?is)^\s*CREATE\s+(?:DEFINER\s*=.+?\s+)?(?:FUNCTION|PROCEDURE)\b").unwrap().is_match(source)
}
fn mysql_validation_routine_name(target_name: &str) -> String {
let mut hash = 0x811c9dc5u32;
for byte in target_name.bytes() {
hash ^= byte as u32;
hash = hash.wrapping_mul(0x01000193);
}
format!("dbx_routine_check_{hash:08x}")
}
fn informix_validation_view_name(target_name: &str) -> String {
let mut hash = 0x811c9dc5u32;
for byte in target_name.bytes() {
@@ -2026,62 +2053,80 @@ mod tests {
#[test]
fn mysql_routine_rename_adds_drop_cleanup() {
let source =
"CREATE DEFINER=`root`@`%` PROCEDURE `refresh_cache_v2`(IN mode_name varchar(20)) BEGIN SELECT 1; END";
let statements = build_executable_object_source_statements(EditableObjectSourceSqlInput {
database_type: DatabaseType::Mysql,
object_type: ObjectSourceKind::Procedure,
schema: Some("app".to_string()),
name: "refresh_cache".to_string(),
source:
"CREATE DEFINER=`root`@`%` PROCEDURE `refresh_cache_v2`(IN mode_name varchar(20)) BEGIN SELECT 1; END"
.to_string(),
source: source.to_string(),
})
.unwrap();
let validation_name = mysql_validation_routine_name("refresh_cache_v2");
let validation_source = replace_mysql_routine_declaration_name(source, &validation_name).unwrap();
assert_eq!(
statements,
vec![
"DROP PROCEDURE IF EXISTS `app`.`refresh_cache_v2`;",
"CREATE DEFINER=`root`@`%` PROCEDURE `refresh_cache_v2`(IN mode_name varchar(20)) BEGIN SELECT 1; END;",
"DROP PROCEDURE IF EXISTS `app`.`refresh_cache`;",
format!("DROP PROCEDURE IF EXISTS `app`.`{validation_name}`;"),
ensure_semicolon(&validation_source),
format!("DROP PROCEDURE IF EXISTS `app`.`{validation_name}`;"),
"DROP PROCEDURE IF EXISTS `app`.`refresh_cache_v2`;".to_string(),
"CREATE DEFINER=`root`@`%` PROCEDURE `refresh_cache_v2`(IN mode_name varchar(20)) BEGIN SELECT 1; END;"
.to_string(),
"DROP PROCEDURE IF EXISTS `app`.`refresh_cache`;".to_string(),
]
);
}
#[test]
fn mysql_procedure_save_replaces_existing_routine() {
let source = "CREATE DEFINER=`root`@`%` PROCEDURE `refresh_cache`() BEGIN SELECT 1; END";
let statements = build_executable_object_source_statements(EditableObjectSourceSqlInput {
database_type: DatabaseType::Mysql,
object_type: ObjectSourceKind::Procedure,
schema: Some("app".to_string()),
name: "refresh_cache".to_string(),
source: "CREATE DEFINER=`root`@`%` PROCEDURE `refresh_cache`() BEGIN SELECT 1; END".to_string(),
source: source.to_string(),
})
.unwrap();
let validation_name = mysql_validation_routine_name("refresh_cache");
let validation_source = replace_mysql_routine_declaration_name(source, &validation_name).unwrap();
assert_eq!(
statements,
vec![
"DROP PROCEDURE IF EXISTS `app`.`refresh_cache`;",
"CREATE DEFINER=`root`@`%` PROCEDURE `refresh_cache`() BEGIN SELECT 1; END;",
format!("DROP PROCEDURE IF EXISTS `app`.`{validation_name}`;"),
ensure_semicolon(&validation_source),
format!("DROP PROCEDURE IF EXISTS `app`.`{validation_name}`;"),
"DROP PROCEDURE IF EXISTS `app`.`refresh_cache`;".to_string(),
"CREATE DEFINER=`root`@`%` PROCEDURE `refresh_cache`() BEGIN SELECT 1; END;".to_string(),
]
);
}
#[test]
fn mysql_function_save_replaces_existing_routine() {
let source = "CREATE DEFINER=CURRENT_USER FUNCTION `active_count`() RETURNS INT RETURN 1";
let statements = build_executable_object_source_statements(EditableObjectSourceSqlInput {
database_type: DatabaseType::Mysql,
object_type: ObjectSourceKind::Function,
schema: Some("app".to_string()),
name: "active_count".to_string(),
source: "CREATE DEFINER=CURRENT_USER FUNCTION `active_count`() RETURNS INT RETURN 1".to_string(),
source: source.to_string(),
})
.unwrap();
let validation_name = mysql_validation_routine_name("active_count");
let validation_source = replace_mysql_routine_declaration_name(source, &validation_name).unwrap();
assert_eq!(
statements,
vec![
"DROP FUNCTION IF EXISTS `app`.`active_count`;",
"CREATE DEFINER=CURRENT_USER FUNCTION `active_count`() RETURNS INT RETURN 1;",
format!("DROP FUNCTION IF EXISTS `app`.`{validation_name}`;"),
ensure_semicolon(&validation_source),
format!("DROP FUNCTION IF EXISTS `app`.`{validation_name}`;"),
"DROP FUNCTION IF EXISTS `app`.`active_count`;".to_string(),
"CREATE DEFINER=CURRENT_USER FUNCTION `active_count`() RETURNS INT RETURN 1;".to_string(),
]
);
}