fix(objects): save SQL Server object source definitions

This commit is contained in:
t8y2
2026-05-11 12:17:18 +08:00
parent 3c0dbb3e30
commit cb70625bd8
10 changed files with 285 additions and 78 deletions
-20
View File
@@ -319,25 +319,6 @@ async function onClickTable(tableName: string) {
}
}
function openObjectSourceEditor(target: { title: string; sql: string; schema?: string }) {
const tab = activeTab.value;
if (!tab) return;
const existing = queryStore.tabs.find(
(item) =>
item.mode === "query" &&
item.connectionId === tab.connectionId &&
item.database === tab.database &&
item.title === target.title,
);
if (existing) {
queryStore.activeTabId = existing.id;
return;
}
const tabId = queryStore.createTab(tab.connectionId, tab.database, target.title);
if (target.schema) queryStore.updateSchema(tabId, target.schema);
queryStore.updateSql(tabId, target.sql);
}
async function changeActiveConnection(connectionId: string) {
const tab = activeTab.value;
if (!tab) return;
@@ -659,7 +640,6 @@ onUnmounted(() => {
tableName: target.tableName,
})
"
@edit-object-source="openObjectSourceEditor"
@object-schema-change="(schema) => activeTab && queryStore.updateSchema(activeTab.id, schema)"
/>
</div>
+22 -2
View File
@@ -18,6 +18,8 @@ const props = defineProps<{
dialect?: "mysql" | "postgres" | "sqlserver";
formatDialect?: SqlFormatDialect;
formatRequestId?: number;
readOnly?: boolean;
forceWordWrap?: boolean;
}>();
const emit = defineEmits<{
@@ -41,6 +43,7 @@ let editorViewModule: typeof import("@codemirror/view") | null = null;
let fontThemeComp: import("@codemirror/state").Compartment | null = null;
let codeMirrorTheme: import("@codemirror/state").Compartment | null = null;
let wordWrapComp: import("@codemirror/state").Compartment | null = null;
let readOnlyComp: import("@codemirror/state").Compartment | null = null;
// Completion cache
let cachedTables: Array<{ name: string; schema?: string; type?: "table" | "view" }> = [];
@@ -78,6 +81,11 @@ function resetZoom() {
setFontSize(13);
}
function wordWrapExtension() {
if (!editorViewModule) return [];
return props.forceWordWrap || settingsStore.editorSettings.wordWrap ? editorViewModule.EditorView.lineWrapping : [];
}
function selectedSqlFromView(currentView: EditorViewType): string {
const selection = currentView.state.selection.main;
return currentView.state.sliceDoc(selection.from, selection.to);
@@ -226,6 +234,7 @@ onMounted(async () => {
fontThemeComp = new Compartment();
codeMirrorTheme = new Compartment();
wordWrapComp = new Compartment();
readOnlyComp = new Compartment();
const ss = settingsStore.editorSettings;
@@ -291,7 +300,8 @@ onMounted(async () => {
bracketMatching(),
Prec.highest(keymap.of([...closeBracketsKeymap, indentWithTab])),
runKeymap,
wordWrapComp.of(ss.wordWrap ? EditorView.lineWrapping : []),
wordWrapComp.of(props.forceWordWrap || ss.wordWrap ? EditorView.lineWrapping : []),
readOnlyComp.of([EditorState.readOnly.of(!!props.readOnly), EditorView.editable.of(!props.readOnly)]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
emit("update:modelValue", update.state.doc.toString());
@@ -489,6 +499,16 @@ watch(
},
);
watch(
() => props.forceWordWrap,
() => {
if (!view.value || !wordWrapComp) return;
view.value.dispatch({
effects: wordWrapComp.reconfigure(wordWrapExtension()),
});
},
);
// Reactively apply editor settings changes
watch(
() => settingsStore.editorSettings,
@@ -498,7 +518,7 @@ watch(
view.value.dispatch({
effects: [
codeMirrorTheme.reconfigure(themeExt),
wordWrapComp.reconfigure(ss.wordWrap ? editorViewModule.EditorView.lineWrapping : []),
wordWrapComp.reconfigure(props.forceWordWrap || ss.wordWrap ? editorViewModule.EditorView.lineWrapping : []),
fontThemeComp.reconfigure(
editorFontTheme(editorViewModule.EditorView, ss.fontSize, ss.fontFamily, {
fixedHeight: true,
-2
View File
@@ -46,7 +46,6 @@ const emit = defineEmits<{
executeSql: [sql: string];
clickTable: [tableName: string];
openObjectTable: [target: { tableName: string; schema?: string }];
editObjectSource: [target: { title: string; sql: string; schema?: string }];
objectSchemaChange: [schema: string | undefined];
}>();
@@ -443,7 +442,6 @@ function onHandleCloseColumnPanel() {
:database="activeTab.database"
:schema="activeTab.objectBrowser?.schema"
@open-table="emit('openObjectTable', $event)"
@edit-source="emit('editObjectSource', $event)"
@schema-change="emit('objectSchemaChange', $event)"
/>
</template>
+123 -25
View File
@@ -12,7 +12,6 @@ import {
Search,
ScrollText,
Table2,
WrapText,
X,
} from "lucide-vue-next";
import { useI18n } from "vue-i18n";
@@ -23,7 +22,9 @@ import * as api from "@/lib/api";
import type { ConnectionConfig, ObjectInfo, ObjectSourceKind } from "@/types/database";
import { isSchemaAware } from "@/lib/databaseCapabilities";
import { useToast } from "@/composables/useToast";
import { buildEditableObjectSourceSql, objectSourceEditTabTitle } from "@/lib/objectSourceEditor";
import { buildExecutableObjectSourceSql, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor";
import QueryEditor from "@/components/editor/QueryEditor.vue";
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
type ObjectRow = {
id: string;
@@ -44,7 +45,6 @@ const props = defineProps<{
const emit = defineEmits<{
openTable: [target: { tableName: string; schema?: string }];
schemaChange: [schema: string | undefined];
editSource: [target: { title: string; sql: string; schema?: string }];
}>();
const { t } = useI18n();
@@ -61,7 +61,10 @@ const sourceLoading = ref(false);
const sourceContent = ref("");
const sourceError = ref("");
const sourceRow = ref<ObjectRow | null>(null);
const sourceWrap = ref(false);
const sourceEditing = ref(false);
const sourceDraft = ref("");
const sourceSaving = ref(false);
const sourceSaveError = ref("");
const error = ref("");
let loadId = 0;
@@ -70,6 +73,22 @@ const tableCount = computed(() => rows.value.filter((row) => row.type === "TABLE
const viewCount = computed(() => rows.value.filter((row) => row.type === "VIEW").length);
const procedureCount = computed(() => rows.value.filter((row) => row.type === "PROCEDURE").length);
const functionCount = computed(() => rows.value.filter((row) => row.type === "FUNCTION").length);
const sourceDialect = computed<"mysql" | "postgres" | "sqlserver">(() => {
if (props.connection.db_type === "postgres" || props.connection.db_type === "gaussdb") return "postgres";
if (props.connection.db_type === "sqlserver") return "sqlserver";
return "mysql";
});
const sourceFormatDialect = computed<SqlFormatDialect>(() => {
switch (props.connection.db_type) {
case "mysql":
case "postgres":
case "sqlite":
case "sqlserver":
return props.connection.db_type;
default:
return "generic";
}
});
const objectFilters = computed<ObjectFilter[]>(() =>
(
[
@@ -157,6 +176,9 @@ async function openSource(row: ObjectRow) {
sourceRow.value = row;
sourceContent.value = "";
sourceError.value = "";
sourceEditing.value = false;
sourceDraft.value = "";
sourceSaveError.value = "";
sourceLoading.value = true;
try {
const result = await api.getObjectSource(
@@ -178,6 +200,9 @@ function closeSource() {
sourceRow.value = null;
sourceContent.value = "";
sourceError.value = "";
sourceEditing.value = false;
sourceDraft.value = "";
sourceSaveError.value = "";
}
function copySource() {
@@ -188,19 +213,45 @@ function copySource() {
function editSource() {
if (!sourceRow.value || !sourceContent.value) return;
sourceDraft.value = sourceContent.value;
sourceSaveError.value = "";
sourceEditing.value = true;
}
function cancelEditSource() {
sourceEditing.value = false;
sourceDraft.value = "";
sourceSaveError.value = "";
}
async function saveSource() {
if (!sourceRow.value || !sourceDraft.value.trim()) return;
const row = sourceRow.value;
const schema = row.schema || selectedSchema.value;
emit("editSource", {
title: objectSourceEditTabTitle(schema, row.name),
schema,
sql: buildEditableObjectSourceSql({
const schema = row.schema || selectedSchema.value || props.database;
sourceSaving.value = true;
sourceSaveError.value = "";
try {
const sql = buildExecutableObjectSourceSql({
databaseType: props.connection.db_type,
objectType: row.type as ObjectSourceKind,
schema,
name: row.name,
source: sourceContent.value,
}),
});
source: sourceDraft.value,
});
if (objectSourceSaveExecutionMode(props.connection.db_type) === "single") {
await api.executeQuery(props.connection.id, props.database, sql, schema);
} else {
await api.executeScript(props.connection.id, props.database, sql, schema);
}
toast(t("objects.sourceSaved"));
sourceEditing.value = false;
sourceDraft.value = "";
await openSource(row);
} catch (e: any) {
sourceSaveError.value = e?.message || String(e);
} finally {
sourceSaving.value = false;
}
}
async function loadSchemas() {
@@ -381,20 +432,46 @@ watch(
<div class="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/20 px-3">
<Code2 class="h-3.5 w-3.5 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-xs font-medium">{{ sourceTitle(sourceRow) }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="!sourceContent" @click="copySource">
<Copy class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="!sourceContent" @click="editSource">
<PencilLine class="h-3 w-3" />
<Button
v-if="sourceEditing"
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
:disabled="sourceSaving || !sourceDraft.trim()"
@click="saveSource"
>
<Loader2 v-if="sourceSaving" class="mr-1 h-3 w-3 animate-spin" />
{{ t("objects.saveSource") }}
</Button>
<Button
v-if="sourceEditing"
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
:disabled="sourceSaving"
@click="cancelEditSource"
>
{{ t("objects.cancelEdit") }}
</Button>
<Button
v-if="!sourceEditing"
variant="ghost"
size="icon"
class="h-5 w-5"
:class="{ 'bg-accent': sourceWrap }"
@click="sourceWrap = !sourceWrap"
:disabled="!sourceContent"
@click="copySource"
>
<WrapText class="h-3 w-3" />
<Copy class="h-3 w-3" />
</Button>
<Button
v-if="!sourceEditing"
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" @click="closeSource">
<X class="h-3 w-3" />
@@ -406,12 +483,33 @@ watch(
<div v-else-if="sourceError" class="flex flex-1 items-center justify-center px-4 text-sm text-destructive">
{{ sourceError }}
</div>
<pre
<div v-else-if="sourceEditing" class="flex min-h-0 flex-1 flex-col">
<QueryEditor
v-model="sourceDraft"
class="min-h-0 flex-1"
:connection-id="props.connection.id"
:database="props.database"
:dialect="sourceDialect"
:format-dialect="sourceFormatDialect"
force-word-wrap
@execute="saveSource"
/>
<div v-if="sourceSaveError" class="shrink-0 border-t px-3 py-2 text-xs text-destructive">
{{ sourceSaveError }}
</div>
</div>
<QueryEditor
v-else
class="min-w-0 flex-1 overflow-auto p-3 font-mono text-xs leading-5"
:class="sourceWrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'"
>{{ sourceContent }}</pre
>
:key="`source-preview-${sourceRow.id}`"
:model-value="sourceContent"
class="min-h-0 flex-1"
:connection-id="props.connection.id"
:database="props.database"
:dialect="sourceDialect"
:format-dialect="sourceFormatDialect"
force-word-wrap
read-only
/>
</div>
</div>
</div>
+3
View File
@@ -567,6 +567,9 @@ export default {
name: "Name",
type: "Type",
source: "Source",
saveSource: "Save",
cancelEdit: "Cancel",
sourceSaved: "Source saved",
schemaColumn: "Schema",
comment: "Comment",
loadingSchemas: "Loading schemas...",
+23 -10
View File
@@ -146,7 +146,8 @@ export default {
jdbcUrl: "URL JDBC",
jdbcUrlPlaceholder: "jdbc:postgresql://localhost:5432/base_de_datos",
jdbcDriverClass: "Clase del driver (opcional)",
jdbcDriverClassPlaceholder: "La mayoría de los drivers se registran automáticamente; usa com.vendor.jdbc.Driver si es necesario",
jdbcDriverClassPlaceholder:
"La mayoría de los drivers se registran automáticamente; usa com.vendor.jdbc.Driver si es necesario",
jdbcDriverPaths: "JARs del driver JDBC",
jdbcDriverSelectPlaceholder: "Seleccionar driver importado",
jdbcDriverPathsPlaceholder: "/ruta/al/driver.jar\n/ruta/a/otro-driver.jar",
@@ -299,7 +300,8 @@ export default {
},
welcome: {
title: "Espacio de trabajo de base de datos",
subtitle: "Selecciona una conexión a la izquierda para explorar el esquema, o abre una pestaña de consulta directamente.",
subtitle:
"Selecciona una conexión a la izquierda para explorar el esquema, o abre una pestaña de consulta directamente.",
connections: "Conexiones",
connected: "Conectado",
databaseTypes: "Tipos de bases de datos",
@@ -363,14 +365,18 @@ export default {
sameName: "Mismo nombre",
},
description: {
foreignKeyIncoming: "{target} apunta al campo actual mediante una clave foránea. Esta es una dependencia verificada.",
foreignKeyOutgoing: "El campo actual referencia a {target} mediante una clave foránea. Esta es una dependencia verificada.",
viewLikely: "La definición de la vista menciona tanto la tabla como el campo destino, lo que generalmente indica una dependencia de consulta.",
foreignKeyIncoming:
"{target} apunta al campo actual mediante una clave foránea. Esta es una dependencia verificada.",
foreignKeyOutgoing:
"El campo actual referencia a {target} mediante una clave foránea. Esta es una dependencia verificada.",
viewLikely:
"La definición de la vista menciona tanto la tabla como el campo destino, lo que generalmente indica una dependencia de consulta.",
viewPossible:
"La definición de la vista menciona un campo con el mismo nombre pero no la tabla destino, por lo que requiere confirmación.",
historyLikely:
"Una sentencia SQL del historial menciona tanto la tabla como el campo destino. Úsala como contexto de análisis de impacto.",
historyPossible: "Una sentencia SQL del historial menciona un campo con el mismo nombre. Puede estar relacionado pero requiere verificación.",
historyPossible:
"Una sentencia SQL del historial menciona un campo con el mismo nombre. Puede estar relacionado pero requiere verificación.",
sameName:
"Otra tabla tiene un campo con el mismo nombre. Puede compartir significado de negocio, pero no es una dependencia verificada en la base de datos.",
},
@@ -462,7 +468,8 @@ export default {
fixWithAi: "Corregir con IA",
truncated: "Contexto truncado",
contextSummary: "{database} · {tables} tablas",
autoSqlBlocked: "El SQL generado por la IA parecía demasiado riesgoso para ejecutarse automáticamente. Revísalo manualmente antes de ejecutar.",
autoSqlBlocked:
"El SQL generado por la IA parecía demasiado riesgoso para ejecutarse automáticamente. Revísalo manualmente antes de ejecutar.",
settingsHint:
"La configuración se almacena en el directorio de datos local de la aplicación. Las solicitudes son enviadas por el backend de Tauri en lugar de directamente desde el frontend.",
actions: {
@@ -566,6 +573,10 @@ export default {
function: "Función",
name: "Nombre",
type: "Tipo",
source: "Código fuente",
saveSource: "Guardar",
cancelEdit: "Cancelar",
sourceSaved: "Código fuente guardado",
schemaColumn: "Esquema",
comment: "Comentario",
loadingSchemas: "Cargando esquemas...",
@@ -724,10 +735,12 @@ export default {
},
dangerDialog: {
title: "Operación peligrosa",
message: "Esta sentencia SQL puede modificar o eliminar datos de forma irreversible. ¿Estás seguro de que deseas ejecutarla?",
message:
"Esta sentencia SQL puede modificar o eliminar datos de forma irreversible. ¿Estás seguro de que deseas ejecutarla?",
deleteMessage: "Esta operación de eliminación puede ser irreversible. ¿Continuar?",
deleteConfirm: "Confirmar eliminación",
deleteRowMessage: "Esta fila quedará marcada para eliminación y se borrará de la base de datos al guardar. ¿Continuar?",
deleteRowMessage:
"Esta fila quedará marcada para eliminación y se borrará de la base de datos al guardar. ¿Continuar?",
deleteRowDetails: "Tabla: {table}",
deleteRowDetailsNoTable: "Fila actual del resultado",
redisKeyDetails: "Clave Redis: {key}",
@@ -914,4 +927,4 @@ export default {
project: "Proyecto",
openSource: "Repositorio de código abierto",
},
};
};
+3
View File
@@ -554,6 +554,9 @@ export default {
name: "名称",
type: "类型",
source: "源代码",
saveSource: "保存",
cancelEdit: "取消",
sourceSaved: "源码已保存",
schemaColumn: "Schema",
comment: "注释",
loadingSchemas: "加载 Schema...",
+8 -6
View File
@@ -8,6 +8,8 @@ type BuildEditableObjectSourceSqlInput = {
source: string;
};
export type ObjectSourceSaveExecutionMode = "single" | "script";
function quotePostgresIdentifier(value: string) {
return `"${value.replaceAll('"', '""')}"`;
}
@@ -24,14 +26,10 @@ function postgresQualifiedName(schema: string | null | undefined, name: string)
.join(".");
}
export function objectSourceEditTabTitle(schema: string | null | undefined, name: string) {
return `Edit source - ${[schema, name].filter(Boolean).join(".")}`;
}
export function buildEditableObjectSourceSql(input: BuildEditableObjectSourceSqlInput) {
export function buildExecutableObjectSourceSql(input: BuildEditableObjectSourceSqlInput) {
const source = input.source.trim();
if (input.databaseType === "sqlserver") {
return source.replace(/^CREATE\s+(?!OR\s+ALTER\b)/i, "CREATE OR ALTER ");
return source.replace(/^CREATE\s+(?:OR\s+ALTER\s+)?/i, "ALTER ");
}
if ((input.databaseType === "postgres" || input.databaseType === "gaussdb") && input.objectType === "VIEW") {
@@ -40,3 +38,7 @@ export function buildEditableObjectSourceSql(input: BuildEditableObjectSourceSql
return ensureSemicolon(source);
}
export function objectSourceSaveExecutionMode(databaseType: DatabaseType): ObjectSourceSaveExecutionMode {
return databaseType === "sqlserver" ? "single" : "script";
}