From f25a449e2097cde24f115e3bdf24a46168691f4d Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 4 Jun 2026 11:42:15 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(sql-editor):=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=AF=B9=E8=B1=A1=E5=AE=9A=E4=B9=89=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 视图、事件、函数/存储过程定义页增加对象修改按钮 - 触发器定义页增加对象修改按钮,并在定义片段场景提示补全 CREATE TRIGGER - 对象修改入口统一打开 query 标签,复用现有 SQL 执行与连接上下文 - 新增定义页对象修改入口组件回归测试 --- .../DefinitionViewer.object-edit.test.tsx | 152 ++++++++++++++++++ frontend/src/components/DefinitionViewer.tsx | 58 ++++++- .../TriggerViewer.object-edit.test.tsx | 116 +++++++++++++ frontend/src/components/TriggerViewer.tsx | 50 +++++- 4 files changed, 367 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/DefinitionViewer.object-edit.test.tsx create mode 100644 frontend/src/components/TriggerViewer.object-edit.test.tsx diff --git a/frontend/src/components/DefinitionViewer.object-edit.test.tsx b/frontend/src/components/DefinitionViewer.object-edit.test.tsx new file mode 100644 index 0000000..7d399ec --- /dev/null +++ b/frontend/src/components/DefinitionViewer.object-edit.test.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { act, create } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { TabData } from '../types'; +import DefinitionViewer from './DefinitionViewer'; + +const storeState = vi.hoisted(() => ({ + connections: [ + { + id: 'conn-1', + name: 'local', + config: { + type: 'postgres', + host: '127.0.0.1', + port: 5432, + user: 'postgres', + password: '', + database: 'main', + }, + }, + ], + theme: 'light', + addTab: vi.fn(), + setActiveContext: vi.fn(), +})); + +const backendApp = vi.hoisted(() => ({ + DBQuery: vi.fn(), +})); + +vi.mock('../store', () => ({ + useStore: (selector: (state: typeof storeState) => any) => selector(storeState), +})); + +vi.mock('../../wailsjs/go/app/App', () => backendApp); + +vi.mock('@ant-design/icons', () => ({ + EditOutlined: () => , +})); + +vi.mock('./MonacoEditor', () => ({ + default: ({ value, options }: any) => ( +
+      {value}
+    
+ ), +})); + +vi.mock('antd', () => ({ + Spin: ({ tip }: any) =>
{tip}
, + Alert: ({ message, description }: any) =>
{message}{description}
, + Button: ({ children, onClick, icon }: any) => ( + + ), +})); + +const flushPromises = async (count = 6) => { + for (let i = 0; i < count; i += 1) { + await Promise.resolve(); + } +}; + +const findButtonText = (node: any): string => ( + (node.children || []) + .map((item: any) => (typeof item === 'string' ? item : findButtonText(item))) + .join('') +); + +const createTab = (overrides: Partial = {}): TabData => ({ + id: 'view-def-conn-1-main-reporting.active_users', + title: '视图: reporting.active_users', + type: 'view-def', + connectionId: 'conn-1', + dbName: 'main', + viewName: 'reporting.active_users', + viewKind: 'view', + schemaName: 'reporting', + ...overrides, +}); + +describe('DefinitionViewer object edit entry', () => { + beforeEach(() => { + storeState.addTab.mockReset(); + storeState.setActiveContext.mockReset(); + storeState.theme = 'light'; + backendApp.DBQuery.mockResolvedValue({ + success: true, + data: [{ view_definition: 'SELECT id, name FROM users' }], + }); + }); + + it('opens an editable query tab for view definitions', async () => { + let renderer: any; + await act(async () => { + renderer = create(); + await flushPromises(); + }); + + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + + await act(async () => { + button.props.onClick(); + }); + + expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'main' }); + expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + title: '修改视图: reporting.active_users', + type: 'query', + connectionId: 'conn-1', + dbName: 'main', + query: expect.stringContaining('CREATE OR REPLACE VIEW reporting.active_users AS'), + })); + expect(storeState.addTab.mock.calls[0][0].query).toContain('SELECT id, name FROM users;'); + }); + + it('opens an editable query tab for routine definitions', async () => { + backendApp.DBQuery.mockResolvedValue({ + success: true, + data: [{ routine_definition: 'CREATE OR REPLACE FUNCTION reporting.refresh_stats() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;' }], + }); + + let renderer: any; + await act(async () => { + renderer = create(); + await flushPromises(); + }); + + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + + await act(async () => { + button.props.onClick(); + }); + + expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + title: '修改函数/存储过程: reporting.refresh_stats', + type: 'query', + query: expect.stringContaining('CREATE OR REPLACE FUNCTION reporting.refresh_stats()'), + })); + }); +}); diff --git a/frontend/src/components/DefinitionViewer.tsx b/frontend/src/components/DefinitionViewer.tsx index 13a2b83..14f7ec6 100644 --- a/frontend/src/components/DefinitionViewer.tsx +++ b/frontend/src/components/DefinitionViewer.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import Editor from './MonacoEditor'; -import { Spin, Alert } from 'antd'; +import { Button, Spin, Alert } from 'antd'; +import { EditOutlined } from '@ant-design/icons'; import { TabData } from '../types'; import { useStore } from '../store'; import { DBQuery } from '../../wailsjs/go/app/App'; @@ -29,6 +30,30 @@ const normalizeMySQLViewDDL = (rawDefinition: unknown): string => { return `${normalized};`; }; +const ensureSqlStatementTerminator = (sql: string): string => { + const normalized = String(sql || '').trim(); + if (!normalized) return ''; + return /;\s*$/.test(normalized) ? normalized : `${normalized};`; +}; + +const buildEditableDefinitionSql = (tab: TabData, definition: string, objectLabel: string, objectName: string): string => { + const normalizedDefinition = String(definition || '').trim(); + const header = `-- 修改${objectLabel}: ${objectName}\n-- 请确认语法兼容当前数据库后执行\n`; + if (!normalizedDefinition) { + return `${header}-- 当前对象定义为空,请补全 ${objectName} 的 DDL 后执行\n`; + } + + if (/^\s*--\s*(未找到|暂不支持|当前)/.test(normalizedDefinition)) { + return `${header}${ensureSqlStatementTerminator(normalizedDefinition)}`; + } + + if (tab.type === 'view-def' && !/^\s*create\b/i.test(normalizedDefinition)) { + return `${header}CREATE OR REPLACE VIEW ${objectName} AS\n${ensureSqlStatementTerminator(normalizedDefinition)}`; + } + + return `${header}${ensureSqlStatementTerminator(normalizedDefinition)}`; +}; + const DefinitionViewer: React.FC = ({ tab }) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -36,6 +61,8 @@ const DefinitionViewer: React.FC = ({ tab }) => { const connections = useStore(state => state.connections); const theme = useStore(state => state.theme); + const addTab = useStore(state => state.addTab); + const setActiveContext = useStore(state => state.setActiveContext); const darkMode = theme === 'dark'; const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''"); @@ -516,6 +543,22 @@ const DefinitionViewer: React.FC = ({ tab }) => { const objectName = tab.type === 'view-def' ? tab.viewName : (tab.type === 'event-def' ? tab.eventName : tab.routineName); + const normalizedObjectName = String(objectName || '').trim(); + + const openObjectEditQuery = () => { + if (!normalizedObjectName) return; + const dbName = String(tab.dbName || '').trim(); + const query = buildEditableDefinitionSql(tab, definition, objectLabel, normalizedObjectName); + setActiveContext({ connectionId: tab.connectionId, dbName }); + addTab({ + id: `query-edit-object-${tab.connectionId}-${dbName}-${Date.now()}`, + title: `修改${objectLabel}: ${normalizedObjectName}`, + type: 'query', + connectionId: tab.connectionId, + dbName, + query, + }); + }; if (loading) { return ( @@ -535,10 +578,15 @@ const DefinitionViewer: React.FC = ({ tab }) => { return (
-
- {objectLabel}: {objectName} - {tab.dbName && 数据库: {tab.dbName}} - {tab.routineType && 类型: {tab.routineType}} +
+
+ {objectLabel}: {objectName} + {tab.dbName && 数据库: {tab.dbName}} + {tab.routineType && 类型: {tab.routineType}} +
+
({ + connections: [ + { + id: 'conn-1', + name: 'local', + config: { + type: 'postgres', + host: '127.0.0.1', + port: 5432, + user: 'postgres', + password: '', + database: 'main', + }, + }, + ], + theme: 'light', + addTab: vi.fn(), + setActiveContext: vi.fn(), +})); + +const backendApp = vi.hoisted(() => ({ + DBQuery: vi.fn(), +})); + +vi.mock('../store', () => ({ + useStore: (selector: (state: typeof storeState) => any) => selector(storeState), +})); + +vi.mock('../../wailsjs/go/app/App', () => backendApp); + +vi.mock('@ant-design/icons', () => ({ + EditOutlined: () => , +})); + +vi.mock('./MonacoEditor', () => ({ + default: ({ value, options }: any) => ( +
+      {value}
+    
+ ), +})); + +vi.mock('antd', () => ({ + Spin: ({ tip }: any) =>
{tip}
, + Alert: ({ message, description }: any) =>
{message}{description}
, + Button: ({ children, onClick, icon }: any) => ( + + ), +})); + +const flushPromises = async (count = 6) => { + for (let i = 0; i < count; i += 1) { + await Promise.resolve(); + } +}; + +const findButtonText = (node: any): string => ( + (node.children || []) + .map((item: any) => (typeof item === 'string' ? item : findButtonText(item))) + .join('') +); + +const tab: TabData = { + id: 'trigger-conn-1-main-audit.users_bi', + title: '触发器: audit.users_bi', + type: 'trigger', + connectionId: 'conn-1', + dbName: 'main', + triggerName: 'audit.users_bi', + triggerTableName: 'audit.users', + schemaName: 'audit', +}; + +describe('TriggerViewer object edit entry', () => { + beforeEach(() => { + storeState.addTab.mockReset(); + storeState.setActiveContext.mockReset(); + backendApp.DBQuery.mockResolvedValue({ + success: true, + data: [{ trigger_definition: 'CREATE TRIGGER users_bi BEFORE INSERT ON audit.users EXECUTE FUNCTION audit.audit_users();' }], + }); + }); + + it('opens an editable query tab for trigger definitions', async () => { + let renderer: any; + await act(async () => { + renderer = create(); + await flushPromises(); + }); + + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + + await act(async () => { + button.props.onClick(); + }); + + expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'main' }); + expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + title: '修改触发器: audit.users_bi', + type: 'query', + connectionId: 'conn-1', + dbName: 'main', + query: expect.stringContaining('CREATE TRIGGER users_bi BEFORE INSERT'), + })); + }); +}); diff --git a/frontend/src/components/TriggerViewer.tsx b/frontend/src/components/TriggerViewer.tsx index 8bf9651..3a281ad 100644 --- a/frontend/src/components/TriggerViewer.tsx +++ b/frontend/src/components/TriggerViewer.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import Editor from './MonacoEditor'; -import { Spin, Alert } from 'antd'; +import { Button, Spin, Alert } from 'antd'; +import { EditOutlined } from '@ant-design/icons'; import { TabData } from '../types'; import { useStore } from '../store'; import { DBQuery } from '../../wailsjs/go/app/App'; @@ -12,6 +13,25 @@ interface TriggerViewerProps { tab: TabData; } +const ensureSqlStatementTerminator = (sql: string): string => { + const normalized = String(sql || '').trim(); + if (!normalized) return ''; + return /;\s*$/.test(normalized) ? normalized : `${normalized};`; +}; + +const buildEditableTriggerSql = (triggerName: string, triggerDefinition: string): string => { + const normalizedName = String(triggerName || '').trim(); + const normalizedDefinition = String(triggerDefinition || '').trim(); + const header = `-- 修改触发器: ${normalizedName}\n-- 请确认语法兼容当前数据库后执行\n`; + if (!normalizedDefinition) { + return `${header}-- 当前触发器定义为空,请补全 CREATE TRIGGER 语句后执行\n`; + } + if (/^\s*create\s+(?:or\s+replace\s+)?trigger\b/i.test(normalizedDefinition)) { + return `${header}${ensureSqlStatementTerminator(normalizedDefinition)}`; + } + return `${header}-- 当前数据源仅返回触发器定义片段,请补全 CREATE TRIGGER 语句后执行\n${ensureSqlStatementTerminator(normalizedDefinition)}`; +}; + const TriggerViewer: React.FC = ({ tab }) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -19,6 +39,8 @@ const TriggerViewer: React.FC = ({ tab }) => { const connections = useStore(state => state.connections); const theme = useStore(state => state.theme); + const addTab = useStore(state => state.addTab); + const setActiveContext = useStore(state => state.setActiveContext); const darkMode = theme === 'dark'; // 透明 Monaco Editor 主题由 MonacoEditor 包装组件按需注册(含 stickyScroll 不透明背景) @@ -304,11 +326,31 @@ LIMIT 1`]; ); } + const triggerName = String(tab.triggerName || '').trim(); + const dbName = String(tab.dbName || '').trim(); + const openObjectEditQuery = () => { + if (!triggerName) return; + setActiveContext({ connectionId: tab.connectionId, dbName }); + addTab({ + id: `query-edit-trigger-${tab.connectionId}-${dbName}-${Date.now()}`, + title: `修改触发器: ${triggerName}`, + type: 'query', + connectionId: tab.connectionId, + dbName, + query: buildEditableTriggerSql(triggerName, triggerDefinition), + }); + }; + return (
-
- 触发器: {tab.triggerName} - {tab.dbName && 数据库: {tab.dbName}} +
+
+ 触发器: {tab.triggerName} + {tab.dbName && 数据库: {tab.dbName}} +
+