diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 3f0d77b..7bc4929 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -114,6 +114,7 @@ vi.mock('../../wailsjs/go/app/App', () => ({ ExecuteSQLFile: mocks.noop, CancelSQLFileExecution: mocks.noop, CreateDatabase: mocks.noop, + CreateSchema: mocks.noop, RenameDatabase: mocks.noop, DropDatabase: mocks.noop, RenameTable: mocks.noop, @@ -651,6 +652,18 @@ describe('Sidebar locate toolbar', () => { expect(markup).toContain('删除数据库 · DROP'); }); + it('renders the v2 database schema action for PostgreSQL-compatible databases', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('新建模式'); + }); + it('renders the v2 connection context menu for host rail actions', () => { const markup = renderToStaticMarkup( { const normalized = String(value || '').trim().toLowerCase(); - if (normalized === 'postgresql') return 'postgres'; + if (normalized === 'postgresql' || normalized === 'pg' || normalized === 'pq' || normalized === 'pgx') return 'postgres'; if (normalized === 'doris') return 'diros'; if ( normalized === 'open_gauss' || @@ -490,6 +491,10 @@ const resolveSavedConnectionDriverType = (conn: SavedConnection | undefined): st return normalizeDriverType(conn?.config?.driver || ''); }; +const isPostgresSchemaDialect = (dialect: string): boolean => ( + ['postgres', 'kingbase', 'highgo', 'vastbase', 'opengauss'].includes(normalizeDriverType(dialect)) +); + const SEARCH_SCOPE_OPTIONS: Array<{ value: SearchScope; label: string }> = [ { value: 'smart', label: '智能' }, { value: 'object', label: '表对象' }, @@ -748,6 +753,9 @@ const Sidebar: React.FC<{ const [isCreateDbModalOpen, setIsCreateDbModalOpen] = useState(false); const [createDbForm] = Form.useForm(); const [targetConnection, setTargetConnection] = useState(null); + const [isCreateSchemaModalOpen, setIsCreateSchemaModalOpen] = useState(false); + const [createSchemaForm] = Form.useForm(); + const [createSchemaTarget, setCreateSchemaTarget] = useState(null); const [isRenameDbModalOpen, setIsRenameDbModalOpen] = useState(false); const [renameDbForm] = Form.useForm(); const [renameDbTarget, setRenameDbTarget] = useState(null); @@ -1109,12 +1117,11 @@ const Sidebar: React.FC<{ }; const getMetadataDialect = (conn: SavedConnection | undefined): string => { - const type = String(conn?.config?.type || '').trim().toLowerCase(); + const type = normalizeDriverType(String(conn?.config?.type || '').trim()); if (type === 'custom') { - const driver = String(conn?.config?.driver || '').trim().toLowerCase(); + const driver = normalizeDriverType(String(conn?.config?.driver || '').trim()); if (driver === 'diros' || driver === 'doris') return 'mysql'; if (driver === 'oceanbase') return normalizeOceanBaseProtocol(conn?.config?.oceanBaseProtocol) === 'oracle' ? 'oracle' : 'mysql'; - if (driver === 'opengauss' || driver === 'open_gauss' || driver === 'open-gauss') return 'opengauss'; return driver; } if (type === 'oceanbase' && normalizeOceanBaseProtocol(conn?.config?.oceanBaseProtocol) === 'oracle') return 'oracle'; @@ -1419,6 +1426,15 @@ const Sidebar: React.FC<{ } }; + const buildSchemasMetadataQuerySpecs = (dialect: string): MetadataQuerySpec[] => { + if (!isPostgresSchemaDialect(dialect)) { + return []; + } + return [{ + sql: `SELECT nspname AS schema_name FROM pg_namespace WHERE nspname NOT IN ('pg_catalog', 'information_schema') AND nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY nspname`, + }]; + }; + const queryMetadataRowsBySpecs = async ( conn: any, dbName: string, @@ -1586,7 +1602,28 @@ const Sidebar: React.FC<{ }); }); return { routines, supported: hasSuccessfulQuery }; - }; + }; + + const loadSchemas = async (conn: any, dbName: string): Promise<{ schemas: string[]; supported: boolean }> => { + const dialect = getMetadataDialect(conn as SavedConnection); + const querySpecs = buildSchemasMetadataQuerySpecs(dialect); + const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); + const seen = new Set(); + const schemas: string[] = []; + + results.forEach((queryResult) => { + queryResult.rows.forEach((row) => { + const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'nspname', 'schemaname']) || getFirstRowValue(row); + if (!schemaName) return; + const key = schemaName.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + schemas.push(schemaName); + }); + }); + + return { schemas, supported: hasSuccessfulQuery }; + }; const fetchDriverStatusMap = async (): Promise> => { const cached = driverStatusCacheRef.current; @@ -1894,7 +1931,8 @@ const Sidebar: React.FC<{ }; }); - const [viewsResult, materializedViewsResult, triggersResult, routinesResult] = await Promise.all([ + const [schemasResult, viewsResult, materializedViewsResult, triggersResult, routinesResult] = await Promise.all([ + loadSchemas(conn, conn.dbName), loadViews(conn, conn.dbName), loadStarRocksMaterializedViews(conn, conn.dbName), loadDatabaseTriggers(conn, conn.dbName), @@ -1932,6 +1970,7 @@ const Sidebar: React.FC<{ const materializedViewRows: string[] = Array.isArray(materializedViewsResult.views) ? materializedViewsResult.views : []; const triggerRows: any[] = Array.isArray(triggersResult.triggers) ? triggersResult.triggers : []; const routineRows: any[] = Array.isArray(routinesResult.routines) ? routinesResult.routines : []; + const schemaRows: string[] = Array.isArray(schemasResult.schemas) ? schemasResult.schemas : []; const viewEntries = viewRows.map((viewName: string) => { const parsed = splitQualifiedName(viewName); @@ -2127,6 +2166,7 @@ const Sidebar: React.FC<{ return bucket; }; + schemaRows.forEach((schemaName) => getSchemaBucket(schemaName)); sortedTableEntries.forEach((entry) => getSchemaBucket(entry.schemaName).tables.push(buildTableNode(entry))); viewEntries.forEach((entry) => getSchemaBucket(entry.schemaName).views.push(buildViewNode(entry))); materializedViewEntries.forEach((entry) => getSchemaBucket(entry.schemaName).materializedViews.push(buildMaterializedViewNode(entry))); @@ -3416,6 +3456,43 @@ const Sidebar: React.FC<{ } }; + const openCreateSchemaModal = (node: any) => { + const dialect = getMetadataDialect(node?.dataRef as SavedConnection); + if (!isPostgresSchemaDialect(dialect)) { + message.warning('当前数据源暂不支持通过此入口新建模式'); + return; + } + setCreateSchemaTarget(node); + createSchemaForm.resetFields(); + setIsCreateSchemaModalOpen(true); + }; + + const handleCreateSchema = async () => { + try { + const values = await createSchemaForm.validateFields(); + const node = createSchemaTarget; + const conn = node?.dataRef; + const dbName = String(conn?.dbName || node?.title || '').trim(); + if (!conn || !dbName) { + message.error('未找到目标数据库,无法新建模式'); + return; + } + + const res = await CreateSchema(buildRpcConnectionConfig(conn.config, { database: dbName }) as any, dbName, values.name); + if (res.success) { + message.success('模式创建成功'); + setIsCreateSchemaModalOpen(false); + setCreateSchemaTarget(null); + createSchemaForm.resetFields(); + await loadTables(node); + } else { + message.error('创建失败: ' + res.message); + } + } catch (e) { + // Validate failed + } + }; + const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => { return buildRpcConnectionConfig(conn.config, { database: resolveSidebarRuntimeDatabase( @@ -4242,6 +4319,9 @@ const Sidebar: React.FC<{ case 'new-table': openNewTableDesign(node); return; + case 'new-schema': + openCreateSchemaModal(node); + return; case 'new-materialized-view': openCreateStarRocksMaterializedView(node); return; @@ -5162,6 +5242,7 @@ const Sidebar: React.FC<{ { setContextMenu(null); @@ -5904,6 +5985,7 @@ const Sidebar: React.FC<{ ]; } else if (node.type === 'database') { const isStarRocks = getMetadataDialect(node.dataRef as SavedConnection) === 'starrocks'; + const supportsSchemaActions = isPostgresSchemaDialect(getMetadataDialect(node.dataRef as SavedConnection)); return [ { key: 'new-table', @@ -5911,6 +5993,14 @@ const Sidebar: React.FC<{ icon: , onClick: () => openNewTableDesign(node) }, + ...(supportsSchemaActions ? [ + { + key: 'new-schema', + label: '新建模式', + icon: , + onClick: () => handleV2DatabaseContextMenuAction(node, 'new-schema') + }, + ] : []), ...(isStarRocks ? [ { key: 'new-materialized-view', @@ -7032,6 +7122,23 @@ const Sidebar: React.FC<{ + { + setIsCreateSchemaModalOpen(false); + setCreateSchemaTarget(null); + createSchemaForm.resetFields(); + }} + > +
+ + + +
+
+ void; }> = ({ dbName, dialect, + supportsSchemaActions = false, supportsStarRocksActions = false, onAction, }) => { @@ -321,6 +325,7 @@ export const V2DatabaseContextMenuView: React.FC<{
{renderItems([ { action: 'new-table', icon: , title: '新建表', kbd: '⌘N', featured: true }, + ...(supportsSchemaActions ? [{ action: 'new-schema', icon: , title: '新建模式' }] : []), { action: 'new-query', icon: , title: '新建查询' }, { action: 'run-sql', icon: , title: '运行外部 SQL 文件' }, ])} diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 999ee30..b98bc63 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -28,6 +28,8 @@ export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):P export function CreateDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise; +export function CreateSchema(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; + export function DBConnect(arg1:connection.ConnectionConfig):Promise; export function DBGetAllColumns(arg1:connection.ConnectionConfig,arg2:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 8665175..190c254 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -46,6 +46,10 @@ export function CreateDatabase(arg1, arg2) { return window['go']['app']['App']['CreateDatabase'](arg1, arg2); } +export function CreateSchema(arg1, arg2, arg3) { + return window['go']['app']['App']['CreateSchema'](arg1, arg2, arg3); +} + export function DBConnect(arg1) { return window['go']['app']['App']['DBConnect'](arg1); } diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index 4662b8c..6305913 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -149,6 +149,56 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string) return connection.QueryResult{Success: true, Message: "数据库创建成功"} } +func isPostgresSchemaDDLDBType(dbType string) bool { + switch resolveDDLDBType(connection.ConnectionConfig{Type: dbType}) { + case "postgres", "kingbase", "highgo", "vastbase", "opengauss": + return true + default: + return false + } +} + +func buildCreateSchemaSQL(dbType string, schemaName string) (string, error) { + schemaName = strings.TrimSpace(schemaName) + if schemaName == "" { + return "", fmt.Errorf("模式名称不能为空") + } + + if !isPostgresSchemaDDLDBType(dbType) { + return "", fmt.Errorf("当前数据源(%s)暂不支持通过此入口新建模式", dbType) + } + + return fmt.Sprintf("CREATE SCHEMA %s", quoteIdentByType(dbType, schemaName)), nil +} + +func (a *App) CreateSchema(config connection.ConnectionConfig, dbName string, schemaName string) connection.QueryResult { + dbType := resolveDDLDBType(config) + targetDbName := strings.TrimSpace(dbName) + if targetDbName == "" { + targetDbName = strings.TrimSpace(config.Database) + } + if targetDbName == "" { + return connection.QueryResult{Success: false, Message: "目标数据库不能为空"} + } + + query, err := buildCreateSchemaSQL(dbType, schemaName) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + runConfig := buildRunConfigForDDL(config, dbType, targetDbName) + dbInst, err := a.getDatabase(runConfig) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + if _, err := dbInst.Exec(query); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + return connection.QueryResult{Success: true, Message: "模式创建成功"} +} + func resolveDDLDBType(config connection.ConnectionConfig) string { dbType := strings.ToLower(strings.TrimSpace(config.Type)) if dbType == "doris" { diff --git a/internal/app/methods_db_create_test.go b/internal/app/methods_db_create_test.go index f7c4768..c27514c 100644 --- a/internal/app/methods_db_create_test.go +++ b/internal/app/methods_db_create_test.go @@ -115,3 +115,58 @@ func TestCreateDatabase_SQLServerUsesBracketIdentifiers(t *testing.T) { t.Fatalf("unexpected SQL Server create database SQL, want %q got %q", want, fakeDB.execQueries[0]) } } + +func TestBuildCreateSchemaSQL_PostgresQuotesSchemaName(t *testing.T) { + got, err := buildCreateSchemaSQL("postgresql", `sales"Ops`) + if err != nil { + t.Fatalf("expected postgres create schema SQL, got error: %v", err) + } + const want = `CREATE SCHEMA "sales""Ops"` + if got != want { + t.Fatalf("unexpected create schema SQL, want %q got %q", want, got) + } +} + +func TestBuildCreateSchemaSQL_RejectsUnsupportedDatabaseType(t *testing.T) { + if _, err := buildCreateSchemaSQL("mysql", "sales"); err == nil { + t.Fatalf("expected unsupported database type error") + } +} + +func TestCreateSchema_CustomPostgresUsesSelectedDatabase(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + t.Cleanup(func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + }) + + fakeDB := &fakeCreateDatabaseDB{} + newDatabaseFunc = func(dbType string) (db.Database, error) { + return fakeDB, nil + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + result := app.CreateSchema(connection.ConnectionConfig{ + Type: "custom", + Driver: "pgx", + Database: "postgres", + }, "tenant_db", `tenant"schema`) + + if !result.Success { + t.Fatalf("expected create schema success, got failure: %s", result.Message) + } + if fakeDB.connectConfig.Database != "tenant_db" { + t.Fatalf("expected create schema connection to use selected database tenant_db, got %q", fakeDB.connectConfig.Database) + } + if len(fakeDB.execQueries) != 1 { + t.Fatalf("expected one create schema statement, got %d: %#v", len(fakeDB.execQueries), fakeDB.execQueries) + } + const want = `CREATE SCHEMA "tenant""schema"` + if fakeDB.execQueries[0] != want { + t.Fatalf("unexpected create schema SQL, want %q got %q", want, fakeDB.execQueries[0]) + } +}