feat(objects): edit database object source
This commit is contained in:
parent
70db0aa62c
commit
03bea705e7
20
src/App.vue
20
src/App.vue
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -566,6 +566,7 @@ export default {
|
|||
function: "Function",
|
||||
name: "Name",
|
||||
type: "Type",
|
||||
source: "Source",
|
||||
schemaColumn: "Schema",
|
||||
comment: "Comment",
|
||||
loadingSchemas: "Loading schemas...",
|
||||
|
|
|
|||
|
|
@ -553,6 +553,7 @@ export default {
|
|||
function: "函数",
|
||||
name: "名称",
|
||||
type: "类型",
|
||||
source: "源代码",
|
||||
schemaColumn: "Schema",
|
||||
comment: "注释",
|
||||
loadingSchemas: "加载 Schema...",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
Loading…
Reference in New Issue