From fa2365153a95a07bc06a9c270c275bb7f4bfb466 Mon Sep 17 00:00:00 2001 From: zipg Date: Sat, 8 Aug 2026 10:38:16 +0800 Subject: [PATCH] fix(kingbase): compat V8 routine source and add extension details --- .../drivers/kingbase-go/integration_test.go | 4 + .../drivers/kingbase-go/kingbase_metadata.go | 26 +++++- agents/drivers/kingbase-go/main.go | 6 ++ agents/drivers/kingbase-go/main_test.go | 92 +++++++++++++++++++ .../objects/ExtensionDetailsDialog.vue | 67 ++++++++++++++ .../__tests__/ExtensionDetailsDialog.spec.ts | 80 ++++++++++++++++ .../src/components/sidebar/ConnectionTree.vue | 11 +++ .../sidebar/SidebarTreeRuntimeHost.vue | 7 ++ apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + apps/desktop/src/lib/sidebar/treeNodeClick.ts | 6 +- packages/app-tests/treeNodeClick.test.ts | 6 ++ 18 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/components/objects/ExtensionDetailsDialog.vue create mode 100644 apps/desktop/src/components/objects/__tests__/ExtensionDetailsDialog.spec.ts diff --git a/agents/drivers/kingbase-go/integration_test.go b/agents/drivers/kingbase-go/integration_test.go index abeaecbb67..dd8a28344f 100644 --- a/agents/drivers/kingbase-go/integration_test.go +++ b/agents/drivers/kingbase-go/integration_test.go @@ -100,6 +100,10 @@ func TestKingbaseIntegration(t *testing.T) { if err != nil || !strings.Contains(fmt.Sprint(source["source"]), function) { t.Fatalf("get function source failed: source=%v err=%v", source, err) } + viewSource, err := server.getObjectSource("public", view, "VIEW") + if err != nil || !strings.Contains(fmt.Sprint(viewSource["source"]), parent) { + t.Fatalf("get view source failed: source=%v err=%v", viewSource, err) + } transactionParams := map[string]json.RawMessage{ "schema": rawJSON("public"), diff --git a/agents/drivers/kingbase-go/kingbase_metadata.go b/agents/drivers/kingbase-go/kingbase_metadata.go index d8fa77f6ee..b78a2388c4 100644 --- a/agents/drivers/kingbase-go/kingbase_metadata.go +++ b/agents/drivers/kingbase-go/kingbase_metadata.go @@ -741,17 +741,35 @@ func (s *server) getObjectSource(schema, name, objectType string) (map[string]an catalog, prefix, function := "sys_catalog", "sys", "sys_get_viewdef" if s.mode.postgresCatalog { catalog, prefix, function = "pg_catalog", "pg", "pg_get_viewdef" + } else if s.usePgViewDefinition { + function = "pg_get_viewdef" + } + querySource := func(definitionFunction string) error { + query := fmt.Sprintf("SELECT %s(c.oid) FROM %s.%s_class c JOIN %s.%s_namespace n ON n.oid=c.relnamespace WHERE n.nspname=%s AND c.relname=%s LIMIT 1", definitionFunction, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(name)) + return s.requireDBQueryRow(query, &source) + } + err = querySource(function) + if err != nil && function == "sys_get_viewdef" && isUndefinedFunction(err, function) { + s.usePgViewDefinition = true + err = querySource("pg_get_viewdef") } - query := fmt.Sprintf("SELECT %s(c.oid) FROM %s.%s_class c JOIN %s.%s_namespace n ON n.oid=c.relnamespace WHERE n.nspname=%s AND c.relname=%s LIMIT 1", function, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(name)) - err = s.requireDBQueryRow(query, &source) } } else if kind == "FUNCTION" || kind == "PROCEDURE" { catalog, prefix, function := "sys_catalog", "sys", "sys_get_functiondef" if s.mode.postgresCatalog { catalog, prefix, function = "pg_catalog", "pg", "pg_get_functiondef" + } else if s.usePgFunctionDefinition { + function = "pg_get_functiondef" + } + querySource := func(definitionFunction string) error { + query := fmt.Sprintf("SELECT %s(p.oid) FROM %s.%s_proc p JOIN %s.%s_namespace n ON n.oid=p.pronamespace WHERE n.nspname=%s AND p.proname=%s ORDER BY CASE WHEN p.prorettype=2278 THEN 0 ELSE 1 END LIMIT 1", definitionFunction, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(name)) + return s.requireDBQueryRow(query, &source) + } + err = querySource(function) + if err != nil && function == "sys_get_functiondef" && isUndefinedFunction(err, function) { + s.usePgFunctionDefinition = true + err = querySource("pg_get_functiondef") } - query := fmt.Sprintf("SELECT %s(p.oid) FROM %s.%s_proc p JOIN %s.%s_namespace n ON n.oid=p.pronamespace WHERE n.nspname=%s AND p.proname=%s ORDER BY CASE WHEN p.prorettype=2278 THEN 0 ELSE 1 END LIMIT 1", function, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(name)) - err = s.requireDBQueryRow(query, &source) } if err != nil && err != sql.ErrNoRows { return nil, err diff --git a/agents/drivers/kingbase-go/main.go b/agents/drivers/kingbase-go/main.go index 510d62f9af..23d45fb609 100644 --- a/agents/drivers/kingbase-go/main.go +++ b/agents/drivers/kingbase-go/main.go @@ -134,6 +134,8 @@ type server struct { params connectParams mode kingbaseMode usePgDefaultExpression bool + usePgViewDefinition bool + usePgFunctionDefinition bool catalogIdentityUnsupported bool infoColumnTypeUnsupported bool infoUdtNameUnsupported bool @@ -456,6 +458,8 @@ func (s *server) connect(cp connectParams) error { s.params = cp s.mode = detectKingbaseMode(db, cp.MySQLCompatMode) s.usePgDefaultExpression = false + s.usePgViewDefinition = false + s.usePgFunctionDefinition = false s.catalogIdentityUnsupported = false s.infoColumnTypeUnsupported = false s.infoUdtNameUnsupported = false @@ -520,6 +524,8 @@ func (s *server) disconnect() error { s.cancelActiveQuery() s.closeAllQuerySessions() s.usePgDefaultExpression = false + s.usePgViewDefinition = false + s.usePgFunctionDefinition = false s.catalogIdentityUnsupported = false s.infoColumnTypeUnsupported = false s.infoUdtNameUnsupported = false diff --git a/agents/drivers/kingbase-go/main_test.go b/agents/drivers/kingbase-go/main_test.go index d1ce5339eb..740307bc3e 100644 --- a/agents/drivers/kingbase-go/main_test.go +++ b/agents/drivers/kingbase-go/main_test.go @@ -1142,6 +1142,98 @@ func TestRoutineSourceUsesKingbaseCatalogFunction(t *testing.T) { } } +func TestObjectSourceFallsBackToPgDefinitionFunctionsAndCachesChoice(t *testing.T) { + tests := []struct { + name string + objectType string + objectName string + sysFunction string + pgFunction string + catalogTable string + source string + }{ + { + name: "function", + objectType: "FUNCTION", + objectName: "format_name", + sysFunction: "sys_get_functiondef", + pgFunction: "pg_get_functiondef", + catalogTable: "sys_catalog.sys_proc", + source: "CREATE FUNCTION public.format_name() RETURNS text AS $$ SELECT 'x'; $$", + }, + { + name: "view", + objectType: "VIEW", + objectName: "active_orders", + sysFunction: "sys_get_viewdef", + pgFunction: "pg_get_viewdef", + catalogTable: "sys_catalog.sys_class", + source: "SELECT * FROM public.orders WHERE active", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + if !strings.Contains(query, test.catalogTable) { + return nil, errors.New("unexpected query: " + query) + } + if strings.Contains(query, test.sysFunction+"(") { + return nil, &gokb.Error{Code: gokb.ErrorCode("42883"), Message: "function " + test.sysFunction + "(oid) does not exist"} + } + if strings.Contains(query, test.pgFunction+"(") { + return &valueRows{columns: []string{"source"}, rows: [][]driver.Value{{test.source}}}, nil + } + return nil, errors.New("unexpected query: " + query) + }} + server := newServer() + server.db = openMetadataDB(t, state) + + for call := 0; call < 2; call++ { + source, err := server.getObjectSource("public", test.objectName, test.objectType) + if err != nil { + t.Fatal(err) + } + if source["source"] != test.source { + t.Fatalf("unexpected source: %#v", source) + } + } + + state.mu.Lock() + queries := append([]string(nil), state.queries...) + state.mu.Unlock() + if len(queries) != 3 || + !strings.Contains(queries[0], test.sysFunction+"(") || + !strings.Contains(queries[1], test.pgFunction+"(") || + !strings.Contains(queries[2], test.pgFunction+"(") { + t.Fatalf("definition fallback choice was not cached: %v", queries) + } + }) + } +} + +func TestObjectSourceDoesNotFallbackOnUnrelatedErrors(t *testing.T) { + permissionErr := errors.New("permission denied for sys_proc") + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + if strings.Contains(query, "sys_get_functiondef(") { + return nil, permissionErr + } + return nil, errors.New("unexpected fallback query: " + query) + }} + server := newServer() + server.db = openMetadataDB(t, state) + + _, err := server.getObjectSource("public", "format_name", "FUNCTION") + if !errors.Is(err, permissionErr) { + t.Fatalf("unexpected error: %v", err) + } + state.mu.Lock() + defer state.mu.Unlock() + if len(state.queries) != 1 || !strings.Contains(state.queries[0], "sys_get_functiondef(") { + t.Fatalf("unrelated error triggered a fallback: %v", state.queries) + } +} + func TestColumnsFallbackToPgGetExprAndCacheChoice(t *testing.T) { registerExpressionFallbackDriver.Do(func() { sql.Register("kingbase-expression-fallback-test", fallbackDriver{}) }) state := &fallbackDriverState{} diff --git a/apps/desktop/src/components/objects/ExtensionDetailsDialog.vue b/apps/desktop/src/components/objects/ExtensionDetailsDialog.vue new file mode 100644 index 0000000000..aea88122c0 --- /dev/null +++ b/apps/desktop/src/components/objects/ExtensionDetailsDialog.vue @@ -0,0 +1,67 @@ + + + diff --git a/apps/desktop/src/components/objects/__tests__/ExtensionDetailsDialog.spec.ts b/apps/desktop/src/components/objects/__tests__/ExtensionDetailsDialog.spec.ts new file mode 100644 index 0000000000..6d323a45c7 --- /dev/null +++ b/apps/desktop/src/components/objects/__tests__/ExtensionDetailsDialog.spec.ts @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, ref, type App } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import type { TreeNode } from "@/types/database"; + +vi.mock("@/components/ui/dialog", async () => { + const { defineComponent, h } = await import("vue"); + const passthrough = defineComponent({ + setup(_props, { slots }) { + return () => h("div", slots.default?.()); + }, + }); + const dialog = defineComponent({ + props: { open: Boolean }, + setup(props, { slots }) { + return () => (props.open ? h("div", slots.default?.()) : null); + }, + }); + return { Dialog: dialog, DialogContent: passthrough, DialogFooter: passthrough, DialogHeader: passthrough, DialogTitle: passthrough }; +}); + +vi.mock("@/components/ui/button", async () => { + const { defineComponent, h } = await import("vue"); + return { + Button: defineComponent({ + setup(_props, { slots }) { + return () => h("button", slots.default?.()); + }, + }), + }; +}); + +import ExtensionDetailsDialog from "@/components/objects/ExtensionDetailsDialog.vue"; + +const mountedApps: App[] = []; + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.innerHTML = ""; +}); + +describe("ExtensionDetailsDialog", () => { + it("shows metadata already loaded for the extension tree node", async () => { + const node: TreeNode = { + id: "connection:database:public:sys_stat_statements", + label: "sys_stat_statements", + type: "extension", + schema: "public", + meta: { + name: "sys_stat_statements", + version: "1.10", + schema: "public", + comment: "track planning and execution statistics", + }, + }; + const dialog = ref | null>(null); + const container = document.createElement("div"); + document.body.append(container); + const app = createApp( + defineComponent({ + setup() { + return () => h(ExtensionDetailsDialog, { ref: dialog, node }); + }, + }), + ); + mountedApps.push(app); + app.use(i18n); + app.mount(container); + dialog.value?.show(); + await nextTick(); + + const text = document.body.textContent || ""; + expect(text).toContain("sys_stat_statements"); + expect(text).toContain("1.10"); + expect(text).toContain("public"); + expect(text).toContain("track planning and execution statistics"); + }); +}); diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index a6db12c61a..030a850246 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -29,6 +29,7 @@ import TreeItem from "./TreeItem.vue"; import SidebarTreeRuntimeHost from "./SidebarTreeRuntimeHost.vue"; import SidebarTreeItemDialogs from "./SidebarTreeItemDialogs.vue"; import InstallExtensionDialog from "@/components/objects/InstallExtensionDialog.vue"; +import ExtensionDetailsDialog from "@/components/objects/ExtensionDetailsDialog.vue"; import { RecycleScroller } from "vue-virtual-scroller"; import "vue-virtual-scroller/dist/vue-virtual-scroller.css"; import LightDropdown from "@/components/ui/LightDropdown.vue"; @@ -79,6 +80,8 @@ const sidebarDangerDialogConfirming = ref(false); const sidebarTreeItemDialogController = ref | null>(null); const sidebarInstallExtensionTarget = ref(null); const sidebarInstallExtensionDialogRef = ref | null>(null); +const sidebarExtensionDetailsTarget = ref(null); +const sidebarExtensionDetailsDialogRef = ref | null>(null); const sidebarTreeRuntimeHostRef = ref(null); const sidebarTreeRuntime = createSidebarTreeRuntime(); const sidebarTreeRuntimeInitialNode: TreeNode = { id: "__sidebar-runtime__", label: "", type: "connection-group" }; @@ -1284,6 +1287,12 @@ async function openSidebarInstallExtension(node: TreeNode) { sidebarInstallExtensionDialogRef.value?.show(); } +async function openSidebarExtensionDetails(node: TreeNode) { + sidebarExtensionDetailsTarget.value = createSidebarActionTarget(node); + await nextTick(); + sidebarExtensionDetailsDialogRef.value?.show(); +} + function beginSidebarAction(): number { sidebarActionGeneration += 1; sidebarDdlOpen.value = false; @@ -1751,6 +1760,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes }); @open-danger-dialog="openSidebarDangerDialog" @open-dialog-controller="updateSidebarTreeItemDialogController" @open-install-extension="openSidebarInstallExtension" + @open-extension-details="openSidebarExtensionDetails" />