feat(postgres): add extension management

This commit is contained in:
amwps290
2026-07-22 15:08:51 +08:00
committed by GitHub
parent 63dd0e88b5
commit 52499f4c9f
13 changed files with 426 additions and 122 deletions
@@ -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) {
+1 -1
View File
@@ -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 })}`);
}
+1 -1
View File
@@ -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 () => {
+51 -44
View File
@@ -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 || "");
}
}
+7 -2
View File
@@ -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()
+26 -13
View File
@@ -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())
}
+215 -1
View File
@@ -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,
+1 -2
View File
@@ -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()))?))
+2 -2
View File
@@ -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]