mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 12:41:39 +00:00
feat(postgres): add extension management
This commit is contained in:
@@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { buildCreateExtensionSql, buildDropExtensionSql } from "@/lib/database/dbAdminSql";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
@@ -23,15 +24,34 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
changed: [];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
const available = ref<ExtensionInfo[]>([]);
|
||||
const installed = ref<ExtensionInfo[]>([]);
|
||||
const schemas = ref<string[]>([]);
|
||||
const selectedSchema = ref<string>("__default__");
|
||||
const loading = ref(false);
|
||||
const installing = ref<string | null>(null);
|
||||
const dropping = ref<string | null>(null);
|
||||
|
||||
function normalizeSchemaOptions(value: string[]): string[] {
|
||||
return [...new Set(value.map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function preferredSchemaOption(options: string[]): string {
|
||||
if (options.includes("public")) return "public";
|
||||
return options[0] ?? "__default__";
|
||||
}
|
||||
|
||||
function installedExtensionSummary(ext: ExtensionInfo): string {
|
||||
const parts = [ext.version];
|
||||
if (ext.schema) parts.push(`schema: ${ext.schema}`);
|
||||
if (ext.comment) parts.push(ext.comment);
|
||||
return parts.filter(Boolean).join(" - ");
|
||||
}
|
||||
|
||||
function show() {
|
||||
open.value = true;
|
||||
void loadData();
|
||||
@@ -41,9 +61,21 @@ async function loadData() {
|
||||
if (!props.node.connectionId || !props.node.database) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const [avail, inst] = await Promise.all([api.listAvailableExtensions(props.node.connectionId, props.node.database).catch(() => [] as ExtensionInfo[]), api.listExtensions(props.node.connectionId, props.node.database, props.node.schema || "public").catch(() => [] as ExtensionInfo[])]);
|
||||
available.value = avail;
|
||||
installed.value = inst;
|
||||
const [availResult, instResult, schemaResult] = await Promise.allSettled([
|
||||
api.listAvailableExtensions(props.node.connectionId, props.node.database),
|
||||
api.listExtensions(props.node.connectionId, props.node.database, props.node.schema),
|
||||
api.listSchemaInfos(props.node.connectionId, props.node.database),
|
||||
]);
|
||||
|
||||
available.value = availResult.status === "fulfilled" ? availResult.value : [];
|
||||
installed.value = instResult.status === "fulfilled" ? instResult.value : [];
|
||||
schemas.value = schemaResult.status === "fulfilled" ? normalizeSchemaOptions(schemaResult.value.map((schema) => schema.name)) : [];
|
||||
selectedSchema.value = props.node.schema && schemas.value.includes(props.node.schema) ? props.node.schema : selectedSchema.value !== "__default__" && schemas.value.includes(selectedSchema.value) ? selectedSchema.value : preferredSchemaOption(schemas.value);
|
||||
|
||||
const firstError = [availResult, instResult, schemaResult].find((result): result is PromiseRejectedResult => result.status === "rejected")?.reason;
|
||||
if (firstError) {
|
||||
throw firstError;
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
} finally {
|
||||
@@ -55,17 +87,18 @@ async function installExtension(name: string) {
|
||||
if (!props.node.connectionId || !props.node.database) return;
|
||||
installing.value = name;
|
||||
try {
|
||||
const sql = buildCreateExtensionSql(name, props.node.schema ?? null);
|
||||
const schema = selectedSchema.value === "__default__" ? null : selectedSchema.value;
|
||||
const sql = buildCreateExtensionSql(name, schema);
|
||||
const result = await executeWithProductionSqlGuard({
|
||||
connection: connectionStore.getConfig(props.node.connectionId),
|
||||
database: props.node.database,
|
||||
sql,
|
||||
source: t("production.sourceExtension"),
|
||||
execute: () => api.executeQuery(props.node.connectionId!, props.node.database!, sql, props.node.schema ?? undefined),
|
||||
execute: () => api.executeQuery(props.node.connectionId!, props.node.database!, sql, schema ?? undefined),
|
||||
});
|
||||
if (!result) return;
|
||||
await loadData();
|
||||
emit("close");
|
||||
emit("changed");
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
} finally {
|
||||
@@ -87,6 +120,7 @@ async function dropExtension(name: string) {
|
||||
});
|
||||
if (!result) return;
|
||||
await loadData();
|
||||
emit("changed");
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
} finally {
|
||||
@@ -108,59 +142,81 @@ defineExpose({ show });
|
||||
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="grid flex-1 min-h-0 grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<!-- Left: Available -->
|
||||
<div class="flex min-h-0 flex-col">
|
||||
<div class="flex items-center gap-1.5 mb-2 text-sm font-medium text-muted-foreground">
|
||||
<Package class="h-4 w-4" />
|
||||
{{ t("extension.available") }}
|
||||
<span class="ml-auto text-xs">({{ available.length }})</span>
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<div class="flex flex-col gap-2 rounded-lg border bg-muted/30 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="font-medium text-foreground">{{ t("extension.available") }}</span>
|
||||
<span>{{ available.length }}</span>
|
||||
<span class="text-border">/</span>
|
||||
<span class="font-medium text-foreground">{{ t("extension.installed") }}</span>
|
||||
<span>{{ installed.length }}</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<div class="text-xs font-medium text-muted-foreground">Schema</div>
|
||||
<Select v-model="selectedSchema">
|
||||
<SelectTrigger class="h-8 w-32 justify-between">
|
||||
<SelectValue :placeholder="'Schema'" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">{{ t("common.default") }}</SelectItem>
|
||||
<SelectItem v-for="schema in schemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<ScrollArea class="min-h-0 flex-1 rounded-md border">
|
||||
<div v-if="available.length === 0" class="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{{ t("extension.noAvailable") }}
|
||||
</div>
|
||||
<div v-else class="divide-y">
|
||||
<div v-for="ext in available" :key="ext.name" class="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium truncate">{{ ext.name }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ ext.comment || ext.version }}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" :disabled="installing === ext.name" @click="installExtension(ext.name)">
|
||||
<Loader2 v-if="installing === ext.name" class="mr-1 h-3 w-3 animate-spin" />
|
||||
<Plus v-else class="mr-1 h-3 w-3" />
|
||||
{{ t("extension.install") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<!-- Right: Installed -->
|
||||
<div class="flex min-h-0 flex-col">
|
||||
<div class="flex items-center gap-1.5 mb-2 text-sm font-medium text-muted-foreground">
|
||||
<Package class="h-4 w-4" />
|
||||
{{ t("extension.installed") }}
|
||||
<span class="ml-auto text-xs">({{ installed.length }})</span>
|
||||
</div>
|
||||
<ScrollArea class="min-h-0 flex-1 rounded-md border">
|
||||
<div v-if="installed.length === 0" class="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{{ t("extension.noInstalled") }}
|
||||
<div class="grid min-h-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<section class="flex min-h-0 flex-col overflow-hidden rounded-lg border bg-card">
|
||||
<div class="flex h-11 shrink-0 items-center gap-2 border-b bg-muted/30 px-3">
|
||||
<Package class="h-4 w-4 text-muted-foreground" />
|
||||
<div class="text-sm font-semibold text-foreground">{{ t("extension.available") }}</div>
|
||||
<span class="ml-auto rounded-full bg-background px-2 py-0.5 text-xs font-medium text-muted-foreground ring-1 ring-border">{{ available.length }}</span>
|
||||
</div>
|
||||
<div v-else class="divide-y">
|
||||
<div v-for="ext in installed" :key="ext.name" class="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium truncate">{{ ext.name }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ ext.version }}{{ ext.comment ? ` — ${ext.comment}` : "" }}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" :disabled="dropping === ext.name" @click="dropExtension(ext.name)">
|
||||
<Loader2 v-if="dropping === ext.name" class="mr-1 h-3 w-3 animate-spin" />
|
||||
<Trash2 v-else class="mr-1 h-3 w-3" />
|
||||
{{ t("extension.drop") }}
|
||||
</Button>
|
||||
<ScrollArea class="min-h-0 flex-1">
|
||||
<div v-if="available.length === 0" class="flex items-center justify-center px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
{{ t("extension.noAvailable") }}
|
||||
</div>
|
||||
<div v-else class="divide-y">
|
||||
<div v-for="ext in available" :key="ext.name" class="flex items-center justify-between gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-foreground">{{ ext.name }}</div>
|
||||
<div class="line-clamp-2 text-xs leading-4 text-muted-foreground">{{ ext.comment || ext.version }}</div>
|
||||
</div>
|
||||
<Button class="shrink-0" size="sm" variant="outline" :disabled="installing === ext.name" @click="installExtension(ext.name)">
|
||||
<Loader2 v-if="installing === ext.name" class="mr-1 h-3 w-3 animate-spin" />
|
||||
<Plus v-else class="mr-1 h-3 w-3" />
|
||||
{{ t("extension.install") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
|
||||
<section class="flex min-h-0 flex-col overflow-hidden rounded-lg border bg-card">
|
||||
<div class="flex h-11 shrink-0 items-center gap-2 border-b bg-muted/30 px-3">
|
||||
<Package class="h-4 w-4 text-muted-foreground" />
|
||||
<div class="text-sm font-semibold text-foreground">{{ t("extension.installed") }}</div>
|
||||
<span class="ml-auto rounded-full bg-background px-2 py-0.5 text-xs font-medium text-muted-foreground ring-1 ring-border">{{ installed.length }}</span>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<ScrollArea class="min-h-0 flex-1">
|
||||
<div v-if="installed.length === 0" class="flex items-center justify-center px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
{{ t("extension.noInstalled") }}
|
||||
</div>
|
||||
<div v-else class="divide-y">
|
||||
<div v-for="ext in installed" :key="ext.name" class="flex items-center justify-between gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-foreground">{{ ext.name }}</div>
|
||||
<div class="line-clamp-2 text-xs leading-4 text-muted-foreground">{{ installedExtensionSummary(ext) }}</div>
|
||||
</div>
|
||||
<Button class="shrink-0" size="sm" variant="outline" :disabled="dropping === ext.name" @click="dropExtension(ext.name)">
|
||||
<Loader2 v-if="dropping === ext.name" class="mr-1 h-3 w-3 animate-spin" />
|
||||
<Trash2 v-else class="mr-1 h-3 w-3" />
|
||||
{{ t("extension.drop") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1051,7 +1051,7 @@ async function executeSidebarProcedureSql(sql: string) {
|
||||
}
|
||||
|
||||
async function refreshSidebarActionTarget() {
|
||||
const target = sidebarObjectSourceTarget.value?.node || sidebarDdlTarget.value;
|
||||
const target = sidebarObjectSourceTarget.value?.node || sidebarDdlTarget.value || sidebarInstallExtensionTarget.value;
|
||||
if (!target) return;
|
||||
const currentTarget = findSidebarActionTarget(store.treeNodes, target);
|
||||
if (!currentTarget) return;
|
||||
@@ -1521,7 +1521,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
|
||||
</template>
|
||||
</SidebarDangerConfirmDialog>
|
||||
<SidebarTreeItemDialogs v-if="sidebarTreeItemDialogController" :key="sidebarTreeItemDialogController.node?.id" :controller="sidebarTreeItemDialogController" @closed="sidebarTreeItemDialogController = null" />
|
||||
<InstallExtensionDialog v-if="sidebarInstallExtensionTarget" ref="sidebarInstallExtensionDialogRef" :node="sidebarInstallExtensionTarget" @close="refreshSidebarActionTarget" />
|
||||
<InstallExtensionDialog v-if="sidebarInstallExtensionTarget" ref="sidebarInstallExtensionDialogRef" :node="sidebarInstallExtensionTarget" @close="refreshSidebarActionTarget" @changed="refreshSidebarActionTarget" />
|
||||
<div v-if="store.treeNodes.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||||
{{ t("sidebar.noConnections") }}
|
||||
</div>
|
||||
|
||||
@@ -518,6 +518,13 @@ async function toggle() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === "group-extensions" && connectionStore.isTreeNodeChildrenLoaded(node.id)) {
|
||||
node.isExpanded = !node.isExpanded;
|
||||
if (wasExpanded && !connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
|
||||
emit("node-toggled", node, wasExpanded);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.isExpanded) {
|
||||
node.isExpanded = false;
|
||||
if (!connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
|
||||
@@ -641,6 +648,8 @@ async function toggle() {
|
||||
await connectionStore.loadIndexes(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
|
||||
} else if (node.type === "group-fkeys" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
|
||||
await connectionStore.loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
|
||||
} else if (node.type === "group-extensions" && node.connectionId && hasTreeNodeDatabaseContext(node)) {
|
||||
await connectionStore.refreshTreeNode(node);
|
||||
}
|
||||
emit("node-toggled", node, wasExpanded);
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -715,7 +715,7 @@ export async function listOwners(connectionId: string, database: string, schema:
|
||||
return get(`/api/schema/owners?${qs({ connection_id: connectionId, database, schema })}`);
|
||||
}
|
||||
|
||||
export async function listExtensions(connectionId: string, database: string, schema: string): Promise<ExtensionInfo[]> {
|
||||
export async function listExtensions(connectionId: string, database: string, schema?: string): Promise<ExtensionInfo[]> {
|
||||
return get(`/api/schema/extensions?${qs({ connection_id: connectionId, database, schema })}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1201,7 +1201,7 @@ export async function listOwners(connectionId: string, database: string, schema:
|
||||
return invoke("list_owners", { connectionId, database, schema });
|
||||
}
|
||||
|
||||
export async function listExtensions(connectionId: string, database: string, schema: string): Promise<ExtensionInfo[]> {
|
||||
export async function listExtensions(connectionId: string, database: string, schema?: string): Promise<ExtensionInfo[]> {
|
||||
return invoke("list_extensions", { connectionId, database, schema });
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ const leafTypes: Set<TreeNodeType> = new Set([
|
||||
"saved-sql-file",
|
||||
"table-search-control",
|
||||
"load-more",
|
||||
"extension",
|
||||
]);
|
||||
|
||||
const fullWidthLabelTypes: Set<TreeNodeType> = new Set(["table", "view", "materialized_view", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index"]);
|
||||
|
||||
@@ -262,7 +262,7 @@ describe("connectionStore metadata loading", () => {
|
||||
await store.loadSchemas(connection.id, "app", { force: true });
|
||||
|
||||
expect(store.connectionErrors[connection.id]).toBeUndefined();
|
||||
expect(store.treeNodes[0]?.children?.[0]?.children?.map((node) => node.label)).toEqual(["public"]);
|
||||
expect(store.treeNodes[0]?.children?.[0]?.children?.map((node) => node.label)).toEqual(["public", "tree.extensions"]);
|
||||
});
|
||||
|
||||
it("clears a failed metadata warning when the driver hint finishes during retry", async () => {
|
||||
|
||||
@@ -1111,6 +1111,18 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return sortSidebarNames([...byName.keys()]).map((name) => byName.get(name)!);
|
||||
}
|
||||
|
||||
function buildExtensionManagementNode(connectionId: string, database: string): TreeNode {
|
||||
return {
|
||||
id: `${connectionId}:${database}:__extensions`,
|
||||
label: "tree.extensions",
|
||||
type: "group-extensions",
|
||||
connectionId,
|
||||
database,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function objectGroupCacheKey(node: TreeNode): string {
|
||||
return schemaCacheKey(node.connectionId || "", node.database || "", node.schema || "", node.type, "objects-v5");
|
||||
}
|
||||
@@ -2840,7 +2852,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
try {
|
||||
await ensureConnected(connectionId);
|
||||
if (useCachedChildren(node, options)) return;
|
||||
const cacheKey = schemaCacheKey(connectionId, database, "schemas-v2");
|
||||
const cacheKey = schemaCacheKey(connectionId, database, "schemas-v3");
|
||||
if (!options?.force) {
|
||||
const cached = await loadPersistedTreeChildren(node, cacheKey);
|
||||
if (cached.hit) {
|
||||
@@ -2857,7 +2869,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
database,
|
||||
),
|
||||
);
|
||||
const children = schemas
|
||||
const children: TreeNode[] = schemas
|
||||
.filter((schema) => visibleSchemaNames.has(schema.name))
|
||||
.map((schema) => {
|
||||
const s = schema.name;
|
||||
@@ -2873,6 +2885,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
if (isPostgresLikeForExtensions(getConfig(connectionId)?.db_type)) {
|
||||
children.push(buildExtensionManagementNode(connectionId, database));
|
||||
}
|
||||
if (isSidebarSearchQueryChanged(options)) return;
|
||||
if (!canApplyTreeMetadataResult(node)) return;
|
||||
setChildren(node, children);
|
||||
@@ -3190,7 +3205,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
await ensureConnected(connectionId);
|
||||
if (useCachedChildren(node, options)) return;
|
||||
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
|
||||
const cacheKey = schemaCacheKey(connectionId, database, schema || "", simpleObjectDisplay ? "objects-simple-v5" : "objects-grouped-v5");
|
||||
const cacheKey = schemaCacheKey(connectionId, database, schema || "", simpleObjectDisplay ? "objects-simple-v5" : "objects-grouped-v6");
|
||||
const searchFilter = activeTreeLoadSearchFilter(options);
|
||||
const isSidebarTableSearch = !!options?.sidebarTableSearchParentId;
|
||||
if (!options?.force && !searchFilter) {
|
||||
@@ -3231,17 +3246,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
schema: effectiveSchema,
|
||||
objectTypes: supportedSidebarObjectTypes(config),
|
||||
});
|
||||
if (isPostgresLikeForExtensions(config?.db_type)) {
|
||||
children.push({
|
||||
id: `${nodeId}:__extensions`,
|
||||
label: "tree.extensions",
|
||||
type: "group-extensions",
|
||||
connectionId,
|
||||
database,
|
||||
schema: effectiveSchema,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
});
|
||||
if (!schema && isPostgresLikeForExtensions(config?.db_type)) {
|
||||
children.push(buildExtensionManagementNode(connectionId, database));
|
||||
}
|
||||
}
|
||||
if (isTreeLoadSearchChanged(searchFilter, options)) return;
|
||||
@@ -3371,35 +3377,6 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
);
|
||||
}
|
||||
|
||||
async function loadExtensions(connectionId: string, database: string, schema: string) {
|
||||
const node = findNode(treeNodes.value, `${connectionId}:${database}:${schema}:__extensions`);
|
||||
if (!node) return;
|
||||
node.isLoading = true;
|
||||
try {
|
||||
await ensureConnected(connectionId);
|
||||
if (useCachedChildren(node)) return;
|
||||
const extensions = await withMetadataLoadTimeout(connectionId, api.listExtensions(connectionId, database, schema), "extensions");
|
||||
const children: TreeNode[] = extensions.map((ext) => ({
|
||||
id: `${node.id}:${ext.name}`,
|
||||
label: ext.name,
|
||||
type: "extension" as const,
|
||||
connectionId,
|
||||
database,
|
||||
schema,
|
||||
comment: ext.comment ?? null,
|
||||
meta: ext,
|
||||
isExpanded: false,
|
||||
}));
|
||||
setChildren(node, children);
|
||||
node.isExpanded = true;
|
||||
} catch (e) {
|
||||
recordMetadataLoadError(connectionId, e);
|
||||
throw e;
|
||||
} finally {
|
||||
node.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreObjectGroupChildren(node: TreeNode) {
|
||||
if (node.type !== "load-more" || !node.loadMore) return;
|
||||
const loadMore = node.loadMore;
|
||||
@@ -3508,6 +3485,36 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
);
|
||||
}
|
||||
|
||||
async function loadExtensions(connectionId: string, database: string) {
|
||||
const node = findNode(treeNodes.value, `${connectionId}:${database}:__extensions`);
|
||||
if (!node) return;
|
||||
node.isLoading = true;
|
||||
try {
|
||||
await ensureConnected(connectionId);
|
||||
if (useCachedChildren(node)) return;
|
||||
const extensions = await withMetadataLoadTimeout(connectionId, api.listExtensions(connectionId, database), "extensions");
|
||||
const children: TreeNode[] = extensions.map((ext) => ({
|
||||
id: `${node.id}:${ext.schema || ""}:${ext.name}`,
|
||||
label: ext.name,
|
||||
type: "extension" as const,
|
||||
connectionId,
|
||||
database,
|
||||
schema: ext.schema ?? undefined,
|
||||
comment: ext.comment ?? null,
|
||||
meta: ext,
|
||||
isExpanded: false,
|
||||
}));
|
||||
setChildren(node, children);
|
||||
node.objectCount = children.length;
|
||||
node.isExpanded = true;
|
||||
} catch (e) {
|
||||
recordMetadataLoadError(connectionId, e);
|
||||
throw e;
|
||||
} finally {
|
||||
node.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTableForLocate(target: LocateTableTarget): Promise<boolean> {
|
||||
const config = getConfig(target.connectionId);
|
||||
if (!config) return false;
|
||||
@@ -3987,7 +3994,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
} else if (node.type === "group-partitions") {
|
||||
node.isExpanded = true;
|
||||
} else if (node.type === "group-extensions" && node.connectionId && hasTreeNodeDatabaseContext(node)) {
|
||||
await loadExtensions(node.connectionId, node.database || "", node.schema || "");
|
||||
await loadExtensions(node.connectionId, node.database || "");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1300,8 +1300,13 @@ pub async fn export_database_sql_core(
|
||||
PostgresExtensionMembers::default()
|
||||
};
|
||||
let postgres_extensions = if request.include_structure && matches!(db_type, DatabaseType::Postgres) {
|
||||
match crate::schema::list_extensions_core(state, &request.connection_id, &request.database, &request.schema)
|
||||
.await
|
||||
match crate::schema::list_extensions_core(
|
||||
state,
|
||||
&request.connection_id,
|
||||
&request.database,
|
||||
Some(&request.schema),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(extensions) => extensions
|
||||
.into_iter()
|
||||
|
||||
@@ -3329,19 +3329,32 @@ pub async fn list_rules(pool: &Pool, schema: &str) -> Result<Vec<RuleInfo>, Stri
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_extensions(pool: &Pool, schema: &str) -> Result<Vec<ExtensionInfo>, String> {
|
||||
pub async fn list_extensions(pool: &Pool, schema: Option<&str>) -> Result<Vec<ExtensionInfo>, String> {
|
||||
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
|
||||
let rows = postgres_query_cached(
|
||||
&client,
|
||||
"SELECT e.extname, COALESCE(e.extversion, '') AS extversion, d.description, n.nspname \
|
||||
FROM pg_catalog.pg_extension e \
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = e.extnamespace \
|
||||
LEFT JOIN pg_catalog.pg_description d ON d.objoid = e.oid AND d.classoid = 'pg_extension'::regclass \
|
||||
WHERE n.nspname = $1 \
|
||||
ORDER BY e.extname",
|
||||
&[&schema],
|
||||
)
|
||||
.await
|
||||
let rows = if let Some(schema) = schema.filter(|value| !value.is_empty()) {
|
||||
postgres_query_cached(
|
||||
&client,
|
||||
"SELECT e.extname, COALESCE(e.extversion, '') AS extversion, d.description, n.nspname \
|
||||
FROM pg_catalog.pg_extension e \
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = e.extnamespace \
|
||||
LEFT JOIN pg_catalog.pg_description d ON d.objoid = e.oid AND d.classoid = 'pg_extension'::regclass \
|
||||
WHERE n.nspname = $1 \
|
||||
ORDER BY e.extname",
|
||||
&[&schema],
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
postgres_query_cached(
|
||||
&client,
|
||||
"SELECT e.extname, COALESCE(e.extversion, '') AS extversion, d.description, n.nspname \
|
||||
FROM pg_catalog.pg_extension e \
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = e.extnamespace \
|
||||
LEFT JOIN pg_catalog.pg_description d ON d.objoid = e.oid AND d.classoid = 'pg_extension'::regclass \
|
||||
ORDER BY n.nspname, e.extname",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
@@ -3350,7 +3363,7 @@ pub async fn list_extensions(pool: &Pool, schema: &str) -> Result<Vec<ExtensionI
|
||||
name: pg_row_try_string(row, 0),
|
||||
version: pg_row_try_string(row, 1),
|
||||
comment: row.try_get::<_, Option<String>>(2).ok().flatten().filter(|s| !s.is_empty()),
|
||||
schema: Some(schema.to_string()),
|
||||
schema: row.try_get::<_, Option<String>>(3).ok().flatten().filter(|s| !s.is_empty()),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -3217,6 +3217,57 @@ mod tests {
|
||||
assert!(!is_agent_postgres_metadata_fallback_config(&test_connection_config(DatabaseType::Mysql)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kingbase_extension_sql_uses_sys_catalog_and_escapes_schema() {
|
||||
let sql = super::kingbase_list_extensions_sql(Some("app's"), super::KingbaseExtensionCatalog::Sys);
|
||||
|
||||
assert!(sql.contains("FROM sys_catalog.sys_extension e"));
|
||||
assert!(sql.contains("JOIN sys_catalog.sys_namespace n"));
|
||||
assert!(sql.contains("LEFT JOIN sys_catalog.sys_description d"));
|
||||
assert!(sql.contains("WHERE n.nspname = 'app''s'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kingbase_available_extension_sql_supports_pg_catalog_fallback() {
|
||||
let sql = super::kingbase_list_available_extensions_sql(super::KingbaseExtensionCatalog::Pg);
|
||||
|
||||
assert!(sql.contains("FROM pg_catalog.pg_available_extensions"));
|
||||
assert!(sql.contains("WHERE installed_version IS NULL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_infos_from_query_result_maps_installed_extensions() {
|
||||
let result = db::QueryResult {
|
||||
columns: vec![
|
||||
"extname".to_string(),
|
||||
"extversion".to_string(),
|
||||
"description".to_string(),
|
||||
"nspname".to_string(),
|
||||
],
|
||||
column_types: Vec::new(),
|
||||
column_sortables: Vec::new(),
|
||||
rows: vec![vec![
|
||||
serde_json::json!("kdb_utils"),
|
||||
serde_json::json!("1.0"),
|
||||
serde_json::json!("KingBase utilities"),
|
||||
serde_json::json!("public"),
|
||||
]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 0,
|
||||
truncated: false,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
};
|
||||
|
||||
let extensions = super::extension_infos_from_query_result(result, true);
|
||||
|
||||
assert_eq!(extensions.len(), 1);
|
||||
assert_eq!(extensions[0].name, "kdb_utils");
|
||||
assert_eq!(extensions[0].version, "1.0");
|
||||
assert_eq!(extensions[0].comment.as_deref(), Some("KingBase utilities"));
|
||||
assert_eq!(extensions[0].schema.as_deref(), Some("public"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_metadata_timeout_defaults_to_sixty_seconds_and_honors_longer_config() {
|
||||
assert_eq!(super::agent_metadata_timeout(None), Some(std::time::Duration::from_secs(60)));
|
||||
@@ -4827,10 +4878,25 @@ pub async fn list_extensions_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
schema: &str,
|
||||
schema: Option<&str>,
|
||||
) -> Result<Vec<db::ExtensionInfo>, String> {
|
||||
retry_metadata_connection(state, connection_id, Some(database), || async {
|
||||
let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?;
|
||||
let db_config = connection_config(state, connection_id).await;
|
||||
if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Kingbase) {
|
||||
let connections = state.connections.read().await;
|
||||
if let Some(client) = extract_pool!(&connections, &pool_key, Agent) {
|
||||
drop(connections);
|
||||
return kingbase_agent_list_extensions(
|
||||
client,
|
||||
database,
|
||||
schema,
|
||||
agent_metadata_timeout(db_config.as_ref()),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let connections = state.connections.read().await;
|
||||
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
|
||||
|
||||
@@ -4849,6 +4915,20 @@ pub async fn list_available_extensions_core(
|
||||
) -> Result<Vec<db::ExtensionInfo>, String> {
|
||||
retry_metadata_connection(state, connection_id, Some(database), || async {
|
||||
let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?;
|
||||
let db_config = connection_config(state, connection_id).await;
|
||||
if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Kingbase) {
|
||||
let connections = state.connections.read().await;
|
||||
if let Some(client) = extract_pool!(&connections, &pool_key, Agent) {
|
||||
drop(connections);
|
||||
return kingbase_agent_list_available_extensions(
|
||||
client,
|
||||
database,
|
||||
agent_metadata_timeout(db_config.as_ref()),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let connections = state.connections.read().await;
|
||||
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
|
||||
|
||||
@@ -4860,6 +4940,140 @@ pub async fn list_available_extensions_core(
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum KingbaseExtensionCatalog {
|
||||
Sys,
|
||||
Pg,
|
||||
}
|
||||
|
||||
impl KingbaseExtensionCatalog {
|
||||
fn catalog_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Sys => "sys_catalog",
|
||||
Self::Pg => "pg_catalog",
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix(self) -> &'static str {
|
||||
match self {
|
||||
Self::Sys => "sys",
|
||||
Self::Pg => "pg",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn kingbase_list_extensions_sql(schema: Option<&str>, catalog: KingbaseExtensionCatalog) -> String {
|
||||
let catalog_name = catalog.catalog_name();
|
||||
let prefix = catalog.prefix();
|
||||
let schema_filter = schema
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|schema| format!("WHERE n.nspname = {}", sql_string(schema)))
|
||||
.unwrap_or_default();
|
||||
let order_by = if schema_filter.is_empty() { "n.nspname, e.extname" } else { "e.extname" };
|
||||
format!(
|
||||
"SELECT e.extname, COALESCE(e.extversion, '') AS extversion, d.description, n.nspname \
|
||||
FROM {catalog_name}.{prefix}_extension e \
|
||||
JOIN {catalog_name}.{prefix}_namespace n ON n.oid = e.extnamespace \
|
||||
LEFT JOIN {catalog_name}.{prefix}_description d ON d.objoid = e.oid AND d.objsubid = 0 \
|
||||
{schema_filter} \
|
||||
ORDER BY {order_by}"
|
||||
)
|
||||
}
|
||||
|
||||
fn kingbase_list_available_extensions_sql(catalog: KingbaseExtensionCatalog) -> String {
|
||||
let catalog_name = catalog.catalog_name();
|
||||
let prefix = catalog.prefix();
|
||||
format!(
|
||||
"SELECT name, default_version, comment \
|
||||
FROM {catalog_name}.{prefix}_available_extensions \
|
||||
WHERE installed_version IS NULL \
|
||||
ORDER BY name"
|
||||
)
|
||||
}
|
||||
|
||||
async fn kingbase_agent_query_result(
|
||||
client: Arc<tokio::sync::Mutex<db::agent_driver::AgentDriverClient>>,
|
||||
database: &str,
|
||||
sql: &str,
|
||||
max_rows: usize,
|
||||
timeout_duration: Option<Duration>,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
let params = agent_execute_query_params(
|
||||
sql,
|
||||
if database.is_empty() { None } else { Some(database) },
|
||||
None,
|
||||
QueryExecutionOptions { max_rows: Some(max_rows), ..Default::default() },
|
||||
);
|
||||
let mut client = client.lock().await;
|
||||
client.execute_query_with_timeout(params, timeout_duration).await
|
||||
}
|
||||
|
||||
async fn kingbase_agent_query_result_with_catalog_fallback(
|
||||
client: Arc<tokio::sync::Mutex<db::agent_driver::AgentDriverClient>>,
|
||||
database: &str,
|
||||
sys_sql: String,
|
||||
pg_sql: String,
|
||||
max_rows: usize,
|
||||
timeout_duration: Option<Duration>,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
match kingbase_agent_query_result(client.clone(), database, &sys_sql, max_rows, timeout_duration).await {
|
||||
Ok(result) => Ok(result),
|
||||
Err(sys_error) => kingbase_agent_query_result(client, database, &pg_sql, max_rows, timeout_duration)
|
||||
.await
|
||||
.map_err(|pg_error| format!("{sys_error}; pg_catalog fallback failed: {pg_error}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn kingbase_agent_list_extensions(
|
||||
client: Arc<tokio::sync::Mutex<db::agent_driver::AgentDriverClient>>,
|
||||
database: &str,
|
||||
schema: Option<&str>,
|
||||
timeout_duration: Option<Duration>,
|
||||
) -> Result<Vec<db::ExtensionInfo>, String> {
|
||||
let result = kingbase_agent_query_result_with_catalog_fallback(
|
||||
client,
|
||||
database,
|
||||
kingbase_list_extensions_sql(schema, KingbaseExtensionCatalog::Sys),
|
||||
kingbase_list_extensions_sql(schema, KingbaseExtensionCatalog::Pg),
|
||||
10_000,
|
||||
timeout_duration,
|
||||
)
|
||||
.await?;
|
||||
Ok(extension_infos_from_query_result(result, true))
|
||||
}
|
||||
|
||||
async fn kingbase_agent_list_available_extensions(
|
||||
client: Arc<tokio::sync::Mutex<db::agent_driver::AgentDriverClient>>,
|
||||
database: &str,
|
||||
timeout_duration: Option<Duration>,
|
||||
) -> Result<Vec<db::ExtensionInfo>, String> {
|
||||
let result = kingbase_agent_query_result_with_catalog_fallback(
|
||||
client,
|
||||
database,
|
||||
kingbase_list_available_extensions_sql(KingbaseExtensionCatalog::Sys),
|
||||
kingbase_list_available_extensions_sql(KingbaseExtensionCatalog::Pg),
|
||||
10_000,
|
||||
timeout_duration,
|
||||
)
|
||||
.await?;
|
||||
Ok(extension_infos_from_query_result(result, false))
|
||||
}
|
||||
|
||||
fn extension_infos_from_query_result(result: db::QueryResult, include_schema: bool) -> Vec<db::ExtensionInfo> {
|
||||
result
|
||||
.rows
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
let name = query_result_cell_string(&row, 0)?;
|
||||
let version = query_result_cell_string(&row, 1).unwrap_or_default();
|
||||
let comment = query_result_cell_string(&row, 2).filter(|value| !value.trim().is_empty());
|
||||
let schema = include_schema.then(|| query_result_cell_string(&row, 3)).flatten();
|
||||
Some(db::ExtensionInfo { name, version, comment, schema })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn list_owners_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
|
||||
@@ -471,8 +471,7 @@ pub async fn list_extensions(
|
||||
Query(q): Query<SchemaQuery>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let database = q.database.as_deref().unwrap_or("");
|
||||
let schema = q.schema.as_deref().unwrap_or("");
|
||||
let result = dbx_core::schema::list_extensions_core(&state.app, &q.connection_id, database, schema)
|
||||
let result = dbx_core::schema::list_extensions_core(&state.app, &q.connection_id, database, q.schema.as_deref())
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?))
|
||||
|
||||
@@ -437,9 +437,9 @@ pub async fn list_extensions(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
schema: String,
|
||||
schema: Option<String>,
|
||||
) -> Result<Vec<db::ExtensionInfo>, String> {
|
||||
dbx_core::schema::list_extensions_core(&state, &connection_id, &database, &schema).await
|
||||
dbx_core::schema::list_extensions_core(&state, &connection_id, &database, schema.as_deref()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
Reference in New Issue
Block a user