feat(objects): edit database object source

This commit is contained in:
t8y2 2026-05-11 11:45:05 +08:00
parent 70db0aa62c
commit 03bea705e7
11 changed files with 272 additions and 8 deletions

View File

@ -319,6 +319,25 @@ 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;
@ -640,6 +659,7 @@ onUnmounted(() => {
tableName: target.tableName,
})
"
@edit-object-source="openObjectSourceEditor"
@object-schema-change="(schema) => activeTab && queryStore.updateSchema(activeTab.id, schema)"
/>
</div>

View File

@ -46,6 +46,7 @@ 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];
}>();
@ -442,6 +443,7 @@ 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>

View File

@ -1,14 +1,29 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { RecycleScroller } from "vue-virtual-scroller";
import { Braces, Eye, Loader2, RefreshCw, Search, ScrollText, Table2 } from "lucide-vue-next";
import {
Braces,
Code2,
Copy,
Eye,
Loader2,
PencilLine,
RefreshCw,
Search,
ScrollText,
Table2,
WrapText,
X,
} from "lucide-vue-next";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import * as api from "@/lib/api";
import type { ConnectionConfig, ObjectInfo } from "@/types/database";
import type { ConnectionConfig, ObjectInfo, ObjectSourceKind } from "@/types/database";
import { isSchemaAware } from "@/lib/databaseCapabilities";
import { useToast } from "@/composables/useToast";
import { buildEditableObjectSourceSql, objectSourceEditTabTitle } from "@/lib/objectSourceEditor";
type ObjectRow = {
id: string;
@ -29,9 +44,11 @@ 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();
const { toast } = useToast();
const schemas = ref<string[]>([]);
const selectedSchema = ref<string | undefined>(props.schema);
@ -40,6 +57,11 @@ const search = ref("");
const objectFilter = ref<ObjectFilter>("all");
const loadingSchemas = ref(false);
const loadingObjects = ref(false);
const sourceLoading = ref(false);
const sourceContent = ref("");
const sourceError = ref("");
const sourceRow = ref<ObjectRow | null>(null);
const sourceWrap = ref(false);
const error = ref("");
let loadId = 0;
@ -112,13 +134,73 @@ function iconClass(type: ObjectRow["type"]) {
return "text-green-500";
}
function canOpen(row: ObjectRow) {
return row.type === "TABLE" || row.type === "VIEW";
function canOpenSource(row: ObjectRow) {
return row.type === "VIEW" || row.type === "PROCEDURE" || row.type === "FUNCTION";
}
function sourceTitle(row: ObjectRow | null) {
if (!row) return t("objects.source");
return `${row.name} ${t("objects.source")}`;
}
function openRow(row: ObjectRow) {
if (!canOpen(row)) return;
emit("openTable", { tableName: row.name, schema: row.schema });
if (row.type === "TABLE") {
emit("openTable", { tableName: row.name, schema: row.schema });
return;
}
if (canOpenSource(row)) {
void openSource(row);
}
}
async function openSource(row: ObjectRow) {
sourceRow.value = row;
sourceContent.value = "";
sourceError.value = "";
sourceLoading.value = true;
try {
const result = await api.getObjectSource(
props.connection.id,
props.database,
row.schema || selectedSchema.value || props.database,
row.name,
row.type as ObjectSourceKind,
);
sourceContent.value = result.source;
} catch (e: any) {
sourceError.value = e?.message || String(e);
} finally {
sourceLoading.value = false;
}
}
function closeSource() {
sourceRow.value = null;
sourceContent.value = "";
sourceError.value = "";
}
function copySource() {
if (!sourceContent.value) return;
navigator.clipboard.writeText(sourceContent.value);
toast(t("grid.copied"));
}
function editSource() {
if (!sourceRow.value || !sourceContent.value) return;
const row = sourceRow.value;
const schema = row.schema || selectedSchema.value;
emit("editSource", {
title: objectSourceEditTabTitle(schema, row.name),
schema,
sql: buildEditableObjectSourceSql({
databaseType: props.connection.db_type,
objectType: row.type as ObjectSourceKind,
schema,
name: row.name,
source: sourceContent.value,
}),
});
}
async function loadSchemas() {
@ -275,11 +357,11 @@ watch(
<div class="truncate">{{ t("objects.schemaColumn") }}</div>
<div v-if="hasComments" class="truncate">{{ t("objects.comment") }}</div>
</div>
<RecycleScroller class="flex-1 min-h-0" :items="filteredRows" :item-size="38" key-field="id">
<RecycleScroller class="min-h-0 flex-1" :items="filteredRows" :item-size="38" key-field="id">
<template #default="{ item }">
<div
class="grid h-[38px] cursor-pointer items-center gap-3 border-b px-3 hover:bg-accent/50"
:class="{ 'cursor-default hover:bg-transparent': !canOpen(item) }"
:class="{ 'bg-accent/40': sourceRow?.id === item.id }"
:style="{ gridTemplateColumns }"
@click="openRow(item)"
>
@ -295,6 +377,42 @@ watch(
</div>
</template>
</RecycleScroller>
<div v-if="sourceRow" class="flex h-[42%] min-h-44 shrink-0 flex-col border-t bg-background">
<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>
<Button
variant="ghost"
size="icon"
class="h-5 w-5"
:class="{ 'bg-accent': sourceWrap }"
@click="sourceWrap = !sourceWrap"
>
<WrapText class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="closeSource">
<X class="h-3 w-3" />
</Button>
</div>
<div v-if="sourceLoading" class="flex flex-1 items-center justify-center">
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
</div>
<div v-else-if="sourceError" class="flex flex-1 items-center justify-center px-4 text-sm text-destructive">
{{ sourceError }}
</div>
<pre
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
>
</div>
</div>
</div>
</template>

View File

@ -566,6 +566,7 @@ export default {
function: "Function",
name: "Name",
type: "Type",
source: "Source",
schemaColumn: "Schema",
comment: "Comment",
loadingSchemas: "Loading schemas...",

View File

@ -553,6 +553,7 @@ export default {
function: "函数",
name: "名称",
type: "类型",
source: "源代码",
schemaColumn: "Schema",
comment: "注释",
loadingSchemas: "加载 Schema...",

View File

@ -57,6 +57,7 @@ export const deleteSchemaCachePrefix = forward("deleteSchemaCachePrefix");
export const listSchemas = forward("listSchemas");
export const listTables = forward("listTables");
export const listObjects = forward("listObjects");
export const getObjectSource = forward("getObjectSource");
export const getColumns = forward("getColumns");
export const listIndexes = forward("listIndexes");
export const listForeignKeys = forward("listForeignKeys");

View File

@ -3,6 +3,8 @@ import type {
DatabaseInfo,
TableInfo,
ObjectInfo,
ObjectSource,
ObjectSourceKind,
ColumnInfo,
IndexInfo,
ForeignKeyInfo,
@ -175,6 +177,18 @@ export async function listObjects(connectionId: string, database: string, schema
return get(`/api/schema/objects?${qs({ connection_id: connectionId, database, schema })}`);
}
export async function getObjectSource(
connectionId: string,
database: string,
schema: string,
name: string,
objectType: ObjectSourceKind,
): Promise<ObjectSource> {
return get(
`/api/schema/object-source?${qs({ connection_id: connectionId, database, schema, table: name, object_type: objectType })}`,
);
}
export async function getColumns(
connectionId: string,
database: string,

View File

@ -0,0 +1,42 @@
import type { DatabaseType, ObjectSourceKind } from "@/types/database";
type BuildEditableObjectSourceSqlInput = {
databaseType: DatabaseType;
objectType: ObjectSourceKind;
schema?: string | null;
name: string;
source: string;
};
function quotePostgresIdentifier(value: string) {
return `"${value.replaceAll('"', '""')}"`;
}
function ensureSemicolon(sql: string) {
const trimmed = sql.trim();
return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
}
function postgresQualifiedName(schema: string | null | undefined, name: string) {
return [schema, name]
.filter(Boolean)
.map((part) => quotePostgresIdentifier(part as string))
.join(".");
}
export function objectSourceEditTabTitle(schema: string | null | undefined, name: string) {
return `Edit source - ${[schema, name].filter(Boolean).join(".")}`;
}
export function buildEditableObjectSourceSql(input: BuildEditableObjectSourceSqlInput) {
const source = input.source.trim();
if (input.databaseType === "sqlserver") {
return source.replace(/^CREATE\s+(?!OR\s+ALTER\b)/i, "CREATE OR ALTER ");
}
if ((input.databaseType === "postgres" || input.databaseType === "gaussdb") && input.objectType === "VIEW") {
return `CREATE OR REPLACE VIEW ${postgresQualifiedName(input.schema, input.name)} AS\n${ensureSemicolon(source)}`;
}
return ensureSemicolon(source);
}

View File

@ -5,6 +5,8 @@ import type {
DatabaseInfo,
TableInfo,
ObjectInfo,
ObjectSource,
ObjectSourceKind,
ColumnInfo,
IndexInfo,
ForeignKeyInfo,
@ -144,6 +146,16 @@ export async function listObjects(connectionId: string, database: string, schema
return invoke("list_objects", { connectionId, database, schema });
}
export async function getObjectSource(
connectionId: string,
database: string,
schema: string,
name: string,
objectType: ObjectSourceKind,
): Promise<ObjectSource> {
return invoke("get_object_source", { connectionId, database, schema, name, objectType });
}
export async function listSchemas(connectionId: string, database: string): Promise<string[]> {
return invoke("list_schemas", { connectionId, database });
}

View File

@ -100,6 +100,15 @@ export interface ObjectInfo {
comment?: string | null;
}
export type ObjectSourceKind = "VIEW" | "PROCEDURE" | "FUNCTION";
export interface ObjectSource {
name: string;
object_type: ObjectSourceKind;
schema?: string | null;
source: string;
}
export interface ColumnInfo {
name: string;
data_type: string;

View File

@ -0,0 +1,44 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { buildEditableObjectSourceSql, objectSourceEditTabTitle } from "../src/lib/objectSourceEditor.ts";
test("SQL Server object source opens as CREATE OR ALTER", () => {
const sql = buildEditableObjectSourceSql({
databaseType: "sqlserver",
objectType: "PROCEDURE",
schema: "dbo",
name: "usp_demo",
source: "CREATE PROCEDURE dbo.usp_demo AS SELECT 1;",
});
assert.equal(sql, "CREATE OR ALTER PROCEDURE dbo.usp_demo AS SELECT 1;");
});
test("SQL Server existing CREATE OR ALTER source is preserved", () => {
const sql = buildEditableObjectSourceSql({
databaseType: "sqlserver",
objectType: "VIEW",
schema: "dbo",
name: "vw_demo",
source: "CREATE OR ALTER VIEW dbo.vw_demo AS SELECT 1 AS id;",
});
assert.equal(sql, "CREATE OR ALTER VIEW dbo.vw_demo AS SELECT 1 AS id;");
});
test("Postgres view body opens as CREATE OR REPLACE VIEW", () => {
const sql = buildEditableObjectSourceSql({
databaseType: "postgres",
objectType: "VIEW",
schema: "public",
name: "active users",
source: " SELECT id, name FROM users WHERE active ",
});
assert.equal(sql, 'CREATE OR REPLACE VIEW "public"."active users" AS\nSELECT id, name FROM users WHERE active;');
});
test("object source edit tab title is stable per schema and object", () => {
assert.equal(objectSourceEditTabTitle("dbo", "usp_demo"), "Edit source - dbo.usp_demo");
assert.equal(objectSourceEditTabTitle(undefined, "usp_demo"), "Edit source - usp_demo");
});