fix(objects): guard system source edits

This commit is contained in:
t8y2 2026-05-18 14:35:33 +08:00
parent 57dea3cb6d
commit 17bc2e66d4
13 changed files with 291 additions and 27 deletions

View File

@ -217,6 +217,26 @@ function openAiPanel() {
}
}
function isActiveObjectSourceReadOnly() {
return !!activeTab.value?.objectSource?.readOnlyReason;
}
function guardWritableObjectSource() {
if (!isActiveObjectSourceReadOnly()) return true;
toast(t("objects.sourceReadOnlySystemObject"), 5000);
return false;
}
function tryExecuteActiveTab() {
if (!guardWritableObjectSource()) return;
tryExecute();
}
function tryExplainActiveTab() {
if (!guardWritableObjectSource()) return;
tryExplain();
}
function analyzeHistoryWithAi(entry: HistoryEntry) {
const connectionId = entry.connection_id || activeTab.value?.connectionId;
if (!connectionId) {
@ -253,6 +273,7 @@ async function openSaveSqlDialog() {
const tab = activeTab.value;
if (!tab || !tab.sql.trim()) return;
if (tab.objectSource) {
if (!guardWritableObjectSource()) return;
await saveActiveObjectSource(tab);
return;
}
@ -282,6 +303,10 @@ async function saveActiveObjectSource(tab: NonNullable<typeof activeTab.value>)
const connection = connectionStore.getConfig(tab.connectionId);
const source = tab.objectSource;
if (!connection || !source) return;
if (source.readOnlyReason) {
toast(t("objects.sourceReadOnlySystemObject"), 5000);
return;
}
try {
const statements = buildExecutableObjectSourceStatements({
@ -563,7 +588,7 @@ function handleKeydown(e: KeyboardEvent) {
) {
e.preventDefault();
e.stopPropagation();
tryExecute();
tryExecuteActiveTab();
}
}
@ -757,9 +782,9 @@ onUnmounted(() => {
:active-tab="activeTab"
:active-connection="activeConnection"
:executable-sql="executableSql"
@execute="tryExecute()"
@execute="tryExecuteActiveTab"
@cancel="cancelActiveExecution()"
@explain="tryExplain()"
@explain="tryExplainActiveTab"
@format-sql="formatActiveSql"
@save-sql="void openSaveSqlDialog()"
@open-sql="openSqlFile"
@ -780,9 +805,9 @@ onUnmounted(() => {
:cursor-pos="cursorPos"
@update:active-output-view="activeOutputView = $event"
@fix-with-ai="fixWithAi"
@execute="tryExecute()"
@execute="tryExecuteActiveTab"
@cancel="cancelActiveExecution()"
@explain="tryExplain()"
@explain="tryExplainActiveTab"
@editor-update="
(v: string) => {
if (queryStore.activeTabId) queryStore.updateSql(queryStore.activeTabId, v);

View File

@ -28,6 +28,7 @@ const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explai
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
import { useQueryStore } from "@/stores/queryStore";
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/queryExecutionState";
import { shouldShowQueryOutputPane } from "@/lib/contentAreaLayout";
import { databaseDisplayNameForTab } from "@/lib/tabPresentation";
import { isTableDataEditable } from "@/lib/tableEditing";
import type { QueryTab, ConnectionConfig } from "@/types/database";
@ -114,6 +115,9 @@ const activeQueryError = computed(() => {
return String(result.rows[0]?.[0] ?? "");
});
const showQueryOutputPane = computed(() => shouldShowQueryOutputPane(props.activeTab));
const objectSourceReadOnly = computed(() => !!props.activeTab.objectSource?.readOnlyReason);
// Column info panel handlers
async function onHandleClickColumn(
matchedCols: Array<{ name: string; table: string; schema?: string }>,
@ -202,7 +206,7 @@ defineExpose({ focusSearch });
<!-- Query mode: editor + results -->
<template v-if="activeTab.mode === 'query'">
<Splitpanes horizontal class="flex-1">
<Pane :size="40" :min-size="15">
<Pane :size="showQueryOutputPane ? 40 : 100" :min-size="showQueryOutputPane ? 15 : 100">
<div class="h-full flex flex-col relative">
<QueryEditor
class="flex-1"
@ -213,6 +217,7 @@ defineExpose({ focusSearch });
:format-dialect="activeSqlFormatDialect"
:format-request-id="formatSqlRequestId"
:execution-error="activeQueryError"
:read-only="objectSourceReadOnly"
@update:model-value="emit('editorUpdate', $event)"
@selection-change="emit('editorSelectionChange', $event)"
@cursor-change="emit('editorCursorChange', $event)"
@ -232,7 +237,7 @@ defineExpose({ focusSearch });
/>
</div>
</Pane>
<Pane :size="60" :min-size="20">
<Pane v-if="showQueryOutputPane" :size="60" :min-size="20">
<div class="h-full flex flex-col">
<div
v-if="

View File

@ -65,7 +65,11 @@ const activeConnectionValue = computed(() => props.activeConnection?.id || "");
const activeSchemaValue = computed(() => props.activeTab.schema || "");
const isSingleDb = computed(() => isSingleDatabase(props.activeConnection?.db_type));
const schemaDatabaseKey = computed(() => props.activeTab.database || (isSingleDb.value ? "_" : ""));
const saveTooltip = computed(() => (props.activeTab.objectSource ? t("objects.saveSource") : t("toolbar.saveSql")));
const objectSourceReadOnly = computed(() => !!props.activeTab.objectSource?.readOnlyReason);
const saveTooltip = computed(() => {
if (objectSourceReadOnly.value) return t("objects.sourceReadOnlySystemObject");
return props.activeTab.objectSource ? t("objects.saveSource") : t("toolbar.saveSql");
});
const showSchemaSelector = computed(() => {
const connection = props.activeConnection;
@ -124,7 +128,10 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
: 'bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20 hover:text-emerald-800 dark:text-emerald-300 dark:hover:text-emerald-200'
"
:disabled="
activeTab.isCancelling || activeTab.isExplaining || (!activeTab.isExecuting && !executableSql.trim())
objectSourceReadOnly ||
activeTab.isCancelling ||
activeTab.isExplaining ||
(!activeTab.isExecuting && !executableSql.trim())
"
@click="activeTab.isExecuting ? emit('cancel') : emit('execute')"
>
@ -148,7 +155,9 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
? ''
: 'text-violet-600 hover:bg-violet-500/10 hover:text-violet-700 dark:text-violet-300 dark:hover:text-violet-200'
"
:disabled="activeTab.isExecuting || (!activeTab.isExplaining && !executableSql.trim())"
:disabled="
objectSourceReadOnly || activeTab.isExecuting || (!activeTab.isExplaining && !executableSql.trim())
"
@click="activeTab.isExplaining ? emit('cancel') : emit('explain')"
>
<Square v-if="activeTab.isExplaining" class="h-3.5 w-3.5 fill-current" />
@ -165,7 +174,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
variant="ghost"
size="icon"
class="h-6 w-6 text-amber-600 hover:bg-amber-500/10 hover:text-amber-700 dark:text-amber-300 dark:hover:text-amber-200"
:disabled="activeTab.isExecuting || activeTab.isExplaining || !activeTab.sql.trim()"
:disabled="objectSourceReadOnly || activeTab.isExecuting || activeTab.isExplaining || !activeTab.sql.trim()"
@click="emit('formatSql')"
>
<AlignLeft class="h-3.5 w-3.5" />
@ -179,7 +188,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
variant="ghost"
size="icon"
class="h-6 w-6 text-blue-600 hover:bg-blue-500/10 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200"
:disabled="!activeTab.sql.trim()"
:disabled="objectSourceReadOnly || !activeTab.sql.trim()"
@click="emit('saveSql')"
>
<Save class="h-3.5 w-3.5" />

View File

@ -34,7 +34,11 @@ import type { ConnectionConfig, ObjectInfo, ObjectSourceKind } from "@/types/dat
import { isSchemaAware } from "@/lib/databaseCapabilities";
import { buildTableSelectSql, qualifiedTableName } from "@/lib/tableSelectSql";
import { useToast } from "@/composables/useToast";
import { buildExecutableObjectSourceStatements, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor";
import {
buildExecutableObjectSourceStatements,
objectSourceReadOnlyReason,
objectSourceSaveExecutionMode,
} from "@/lib/objectSourceEditor";
import { buildRenameObjectSql, supportsObjectRename } from "@/lib/objectRenameSql";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
@ -108,6 +112,7 @@ const sourceFormatDialect = computed<SqlFormatDialect>(() => {
return "generic";
}
});
const sourceRowReadOnlyReason = computed(() => (sourceRow.value ? readOnlyReasonForRow(sourceRow.value) : null));
const objectFilters = computed<ObjectFilter[]>(() =>
(
[
@ -169,7 +174,17 @@ function canOpenSource(row: ObjectBrowserRow) {
}
function canRename(row: ObjectBrowserRow) {
return supportsObjectRename(props.connection.db_type, row.type);
return !readOnlyReasonForRow(row) && supportsObjectRename(props.connection.db_type, row.type);
}
function readOnlyReasonForRow(row: ObjectBrowserRow) {
if (row.type !== "VIEW" && row.type !== "PROCEDURE" && row.type !== "FUNCTION") return null;
return objectSourceReadOnlyReason({
databaseType: props.connection.db_type,
schema: row.schema || selectedSchema.value || props.database,
name: row.name,
objectType: row.type,
});
}
function sourceTitle(row: ObjectBrowserRow | null) {
@ -205,7 +220,7 @@ async function openSource(row: ObjectBrowserRow) {
);
sourceContent.value = result.source;
sourceDraft.value = result.source;
sourceEditing.value = true;
sourceEditing.value = !readOnlyReasonForRow(row);
} catch (e: any) {
sourceError.value = e?.message || String(e);
} finally {
@ -235,11 +250,19 @@ function qualifiedName(row: ObjectBrowserRow): string {
}
function requestDrop(row: ObjectBrowserRow) {
if (readOnlyReasonForRow(row)) {
toast(t("objects.sourceReadOnlySystemObject"), 5000);
return;
}
dropTarget.value = row;
showDropConfirm.value = true;
}
function requestRename(row: ObjectBrowserRow) {
if (readOnlyReasonForRow(row)) {
toast(t("objects.sourceReadOnlySystemObject"), 5000);
return;
}
renameTarget.value = row;
renameInput.value = row.name;
renameError.value = "";
@ -267,6 +290,10 @@ async function confirmRename() {
const row = renameTarget.value;
const newName = renameInput.value.trim();
if (!row || !newName || newName === row.name) return;
if (readOnlyReasonForRow(row)) {
renameError.value = t("objects.sourceReadOnlySystemObject");
return;
}
renameError.value = "";
try {
const schema = row.schema || selectedSchema.value || props.database;
@ -295,6 +322,11 @@ async function confirmRename() {
async function confirmDrop() {
if (!dropTarget.value) return;
const row = dropTarget.value;
if (readOnlyReasonForRow(row)) {
toast(t("objects.sourceReadOnlySystemObject"), 5000);
dropTarget.value = null;
return;
}
const typeSql =
row.type === "VIEW"
? "VIEW"
@ -363,6 +395,10 @@ function copySource() {
function editSource() {
if (!sourceRow.value || !sourceContent.value) return;
if (sourceRowReadOnlyReason.value) {
toast(t("objects.sourceReadOnlySystemObject"), 5000);
return;
}
sourceDraft.value = sourceContent.value;
sourceSaveError.value = "";
sourceEditing.value = true;
@ -376,6 +412,10 @@ function cancelEditSource() {
async function saveSource() {
if (!sourceRow.value || !sourceDraft.value.trim()) return;
if (sourceRowReadOnlyReason.value) {
toast(t("objects.sourceReadOnlySystemObject"), 5000);
return;
}
const row = sourceRow.value;
const schema = row.schema || selectedSchema.value || props.database;
sourceSaving.value = true;
@ -651,11 +691,13 @@ watch(
<ContextMenuItem v-if="canRename(item)" @click="requestRename(item)">
<Pencil class="w-4 h-4 mr-2" /> {{ t("contextMenu.renameObject") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem class="text-destructive" @click="requestDrop(item)">
<Trash2 class="w-4 h-4 mr-2" />
{{ item.type === "PROCEDURE" ? t("contextMenu.dropProcedure") : t("contextMenu.dropFunction") }}
</ContextMenuItem>
<template v-if="!readOnlyReasonForRow(item)">
<ContextMenuSeparator />
<ContextMenuItem class="text-destructive" @click="requestDrop(item)">
<Trash2 class="w-4 h-4 mr-2" />
{{ item.type === "PROCEDURE" ? t("contextMenu.dropProcedure") : t("contextMenu.dropFunction") }}
</ContextMenuItem>
</template>
</template>
</ContextMenuContent>
</ContextMenu>
@ -701,7 +743,8 @@ watch(
variant="ghost"
size="icon"
class="h-5 w-5"
:disabled="!sourceContent"
:disabled="!sourceContent || !!sourceRowReadOnlyReason"
:title="sourceRowReadOnlyReason ? t('objects.sourceReadOnlySystemObject') : undefined"
@click="editSource"
>
<PencilLine class="h-3 w-3" />
@ -724,6 +767,7 @@ watch(
:database="props.database"
:dialect="sourceDialect"
:format-dialect="sourceFormatDialect"
:read-only="!!sourceRowReadOnlyReason"
force-word-wrap
@save="saveSource"
/>

View File

@ -92,6 +92,7 @@ import {
treeNodeRowAction,
treeNodeRowDoubleClickAction,
} from "@/lib/treeNodeClick";
import { objectSourceReadOnlyReason } from "@/lib/objectSourceEditor";
import { formatCsv, formatJson, formatSqlInsert } from "@/lib/exportFormats";
import { fetchTableDataForExport } from "@/lib/tableDataExport";
import {
@ -659,6 +660,18 @@ function viewObjectSource() {
schema,
name: node.label,
objectType,
readOnlyReason: (() => {
const databaseType = currentDatabaseType();
if (!databaseType) return undefined;
return (
objectSourceReadOnlyReason({
databaseType,
schema,
name: node.label,
objectType,
}) ?? undefined
);
})(),
});
})
.catch((e: any) => {
@ -667,6 +680,10 @@ function viewObjectSource() {
}
function requestDropObject() {
if (currentObjectSourceReadOnlyReason.value) {
toast(t("objects.sourceReadOnlySystemObject"), 5000);
return;
}
showDropObjectConfirm.value = true;
}
@ -680,7 +697,21 @@ function nodeRenameObjectType(): RenameableObjectType | null {
const canRenameObject = computed(() => {
const objectType = nodeRenameObjectType();
return !!objectType && supportsObjectRename(currentDatabaseType(), objectType);
return (
!!objectType && !currentObjectSourceReadOnlyReason.value && supportsObjectRename(currentDatabaseType(), objectType)
);
});
const currentObjectSourceReadOnlyReason = computed(() => {
const objectType = objectSourceKindForTreeNode(props.node.type);
const databaseType = currentDatabaseType();
if (!objectType || !databaseType) return null;
return objectSourceReadOnlyReason({
databaseType,
schema: props.node.schema,
name: props.node.label,
objectType,
});
});
function openRenameObjectDialog() {
@ -1968,11 +1999,13 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
<Pencil class="w-4 h-4 mr-2" />
{{ t("contextMenu.renameObject") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem class="text-destructive" @click="requestDropObject">
<Trash2 class="w-4 h-4 mr-2" />
{{ node.type === "procedure" ? t("contextMenu.dropProcedure") : t("contextMenu.dropFunction") }}
</ContextMenuItem>
<template v-if="!currentObjectSourceReadOnlyReason">
<ContextMenuSeparator />
<ContextMenuItem class="text-destructive" @click="requestDropObject">
<Trash2 class="w-4 h-4 mr-2" />
{{ node.type === "procedure" ? t("contextMenu.dropProcedure") : t("contextMenu.dropFunction") }}
</ContextMenuItem>
</template>
</template>
<template v-if="isGroupLabel(node)">

View File

@ -758,6 +758,7 @@ export default {
cancelEdit: "Cancel",
sourceSaved: "Source saved",
sourceSaveFailed: "Failed to save source: {message}",
sourceReadOnlySystemObject: "System objects are protected and cannot be modified or deleted",
schemaColumn: "Schema",
comment: "Comment",
loadingSchemas: "Loading schemas...",

View File

@ -663,6 +663,7 @@ export default {
cancelEdit: "Cancelar",
sourceSaved: "Código fuente guardado",
sourceSaveFailed: "Error al guardar el código fuente: {message}",
sourceReadOnlySystemObject: "Los objetos del sistema están protegidos y no se pueden modificar ni eliminar",
schemaColumn: "Esquema",
comment: "Comentario",
loadingSchemas: "Cargando esquemas...",

View File

@ -739,6 +739,7 @@ export default {
cancelEdit: "取消",
sourceSaved: "源码已保存",
sourceSaveFailed: "保存源码失败:{message}",
sourceReadOnlySystemObject: "系统对象受保护,不能修改或删除",
schemaColumn: "Schema",
comment: "注释",
loadingSchemas: "加载 Schema...",

View File

@ -0,0 +1,13 @@
import type { QueryTab } from "@/types/database";
type QueryOutputPaneState = Pick<
QueryTab,
"mode" | "objectSource" | "result" | "explainPlan" | "explainError" | "isExecuting" | "isExplaining"
>;
export function shouldShowQueryOutputPane(tab: QueryOutputPaneState): boolean {
if (tab.mode !== "query") return false;
if (!tab.objectSource) return true;
return !!(tab.result || tab.explainPlan || tab.explainError || tab.isExecuting || tab.isExplaining);
}

View File

@ -9,6 +9,14 @@ type BuildEditableObjectSourceSqlInput = {
};
export type ObjectSourceSaveExecutionMode = "single" | "script";
export type ObjectSourceReadOnlyReason = "system-object";
type ObjectSourceReadOnlyInput = {
databaseType: DatabaseType;
schema?: string | null;
name: string;
objectType: ObjectSourceKind;
};
const postgresLikeRoutineRenameTypes = new Set<DatabaseType>([
"postgres",
@ -98,3 +106,37 @@ export function buildExecutableObjectSourceSql(input: BuildEditableObjectSourceS
export function objectSourceSaveExecutionMode(_databaseType: DatabaseType): ObjectSourceSaveExecutionMode {
return "single";
}
const readOnlySystemSchemas = new Set([
"information_schema",
"pg_catalog",
"sys",
"system",
"mysql",
"performance_schema",
"xdb",
"outln",
"dbsnmp",
"ctisys",
"sysauditor",
"syssso",
]);
const damengSysdbaRoutinePrefixes = ["SP_TS_", "SP_ARCH_", "SP_DB_", "SP_DROP_CONS_", "SP_TAB_", "SP_UPDATE_SYS"];
export function objectSourceReadOnlyReason(input: ObjectSourceReadOnlyInput): ObjectSourceReadOnlyReason | null {
const schema = input.schema?.trim().toLowerCase();
if (schema && readOnlySystemSchemas.has(schema)) return "system-object";
const name = input.name.trim().toUpperCase();
if (
input.databaseType === "dameng" &&
input.schema?.trim().toUpperCase() === "SYSDBA" &&
(input.objectType === "PROCEDURE" || input.objectType === "FUNCTION") &&
damengSysdbaRoutinePrefixes.some((prefix) => name.startsWith(prefix))
) {
return "system-object";
}
return null;
}

View File

@ -289,6 +289,7 @@ export interface QueryTab {
schema?: string;
name: string;
objectType: ObjectSourceKind;
readOnlyReason?: "system-object";
};
tableMeta?: {
schema?: string;

View File

@ -0,0 +1,54 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { shouldShowQueryOutputPane } from "../../apps/desktop/src/lib/contentAreaLayout.ts";
test("object source tabs hide the query output pane while idle", () => {
assert.equal(
shouldShowQueryOutputPane({
mode: "query",
objectSource: { objectType: "PROCEDURE", name: "refresh_cache" },
isExecuting: false,
isExplaining: false,
}),
false,
);
});
test("object source tabs show the query output pane after execution starts or returns feedback", () => {
assert.equal(
shouldShowQueryOutputPane({
mode: "query",
objectSource: { objectType: "FUNCTION", name: "calc_total" },
isExecuting: true,
isExplaining: false,
}),
true,
);
assert.equal(
shouldShowQueryOutputPane({
mode: "query",
objectSource: { objectType: "PROCEDURE", name: "refresh_cache" },
isExecuting: false,
isExplaining: false,
result: {
columns: ["Error"],
rows: [["syntax error"]],
affected_rows: 0,
execution_time_ms: 2,
},
}),
true,
);
});
test("regular query tabs keep the output pane visible before execution", () => {
assert.equal(
shouldShowQueryOutputPane({
mode: "query",
isExecuting: false,
isExplaining: false,
}),
true,
);
});

View File

@ -3,6 +3,7 @@ import test from "node:test";
import {
buildExecutableObjectSourceSql,
buildExecutableObjectSourceStatements,
objectSourceReadOnlyReason,
objectSourceSaveExecutionMode,
} from "../../apps/desktop/src/lib/objectSourceEditor.ts";
@ -104,3 +105,37 @@ test("object source SQL joins generated save statements for previews", () => {
'CREATE OR REPLACE PROCEDURE "public"."refresh_cache_v2"(mode text)\nLANGUAGE SQL\nAS $$ SELECT 1 $$;\nDROP PROCEDURE IF EXISTS "public"."refresh_cache"(mode text);',
);
});
test("system schemas open object source as read-only", () => {
assert.equal(
objectSourceReadOnlyReason({
databaseType: "oracle",
schema: "SYS",
name: "DBMS_STATS",
objectType: "PROCEDURE",
}),
"system-object",
);
});
test("Dameng built-in maintenance routines open as read-only without blocking user SP names", () => {
assert.equal(
objectSourceReadOnlyReason({
databaseType: "dameng",
schema: "SYSDBA",
name: "SP_TS_BAKSET_REMOVE_BATCH",
objectType: "PROCEDURE",
}),
"system-object",
);
assert.equal(
objectSourceReadOnlyReason({
databaseType: "dameng",
schema: "SYSDBA",
name: "SP_HELLO",
objectType: "PROCEDURE",
}),
null,
);
});