From adacf0b5c52f2e0485a4eaf6ea4114772395d41d Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 23 Jun 2026 17:42:54 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20feat(connection):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=94=9F=E4=BA=A7=E8=BF=9E=E6=8E=A5=E5=A4=9A=E9=A1=B9?= =?UTF-8?q?=E4=BF=9D=E6=8A=A4=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增数据编辑、结构编辑、脚本执行和数据导入四类连接级保护配置 - 升级生产连接保护弹窗为多选卡片,并修复选项对齐与勾选态显示 - 按保护类型收口 QueryEditor、DataGrid、表设计、导入与同步目标入口 - 后端统一拦截 SQL 或 Mongo 写操作、结果编辑、结构变更和导入写入 - AI 本地工具与 RPC 执行链路透传连接保护配置并复用后端守卫 - 补充多语言文案、定向测试与需求追踪记录 --- .../需求进度追踪-生产连接只读保护-20260623.md | 47 +-- .../components/ConnectionModal.i18n.test.tsx | 63 ++++ frontend/src/components/ConnectionModal.tsx | 8 +- frontend/src/components/DataGrid.tsx | 6 +- frontend/src/components/DataGridShell.tsx | 3 +- .../QueryEditor.external-sql-save.test.tsx | 42 +++ frontend/src/components/QueryEditor.tsx | 88 +++++- frontend/src/components/Sidebar.tsx | 9 +- frontend/src/components/TableOverview.tsx | 3 +- .../connectionModal/ConnectionModalStep2.tsx | 278 +++++++++++++++++- .../connectionModal/connectionModalConfig.ts | 29 +- frontend/src/i18n/i18n.test.ts | 70 +++++ frontend/src/store.ts | 13 + frontend/src/types.ts | 8 + .../utils/connectionModalPresentation.test.ts | 1 + .../src/utils/connectionModalPresentation.ts | 2 + frontend/src/utils/connectionReadOnly.test.ts | 57 ++++ frontend/src/utils/connectionReadOnly.ts | 129 +++++++- frontend/src/utils/connectionRpcConfig.ts | 8 +- .../src/utils/dataSourceCapabilities.test.ts | 34 +++ frontend/src/utils/dataSourceCapabilities.ts | 27 +- frontend/wailsjs/go/models.ts | 21 ++ internal/app/connection_readonly.go | 80 ++++- internal/app/connection_readonly_test.go | 34 ++- internal/app/methods_db.go | 28 +- internal/app/methods_db_transaction.go | 2 +- internal/app/methods_file.go | 10 +- internal/app/methods_sync.go | 25 +- internal/connection/types.go | 99 ++++--- shared/i18n/de-DE.json | 20 +- shared/i18n/en-US.json | 20 +- shared/i18n/ja-JP.json | 20 +- shared/i18n/ru-RU.json | 20 +- shared/i18n/zh-CN.json | 20 +- shared/i18n/zh-TW.json | 20 +- 35 files changed, 1184 insertions(+), 160 deletions(-) create mode 100644 frontend/src/utils/connectionReadOnly.test.ts diff --git a/docs/需求追踪/需求进度追踪-生产连接只读保护-20260623.md b/docs/需求追踪/需求进度追踪-生产连接只读保护-20260623.md index 60fab97..3ff3f8a 100644 --- a/docs/需求追踪/需求进度追踪-生产连接只读保护-20260623.md +++ b/docs/需求追踪/需求进度追踪-生产连接只读保护-20260623.md @@ -4,24 +4,24 @@ - 需求名称:生产连接只读保护 - 提出日期:2026-06-23 - 负责人:Codex -- 目标:为 SQL 类数据库与 MongoDB 连接增加连接级只读保护,启用后仅允许查询,阻止写入、DDL、导入和同步目标操作 +- 目标:为 SQL 类数据库与 MongoDB 连接增加类似 DBeaver 的生产连接保护,支持按项勾选数据编辑、结构编辑、脚本执行、数据导入四类限制 - 非目标:不为所有侧栏写操作都新增前端隐藏逻辑;不引入新的环境分级体系;不改造 JVM 只读能力 ## 2. 范围与验收 - 范围: -- 连接配置模型、保存/回填与 RPC 序列化链路 -- 连接弹窗只读开关、查询编辑器本地拦截、DataGrid 导入入口收口 -- 后端 SQL/Mongo 查询判定与写操作统一守卫 +- 连接配置模型、保存/回填、持久化与 RPC 序列化链路 +- 连接弹窗生产保护多选项 UI、查询编辑器本地拦截、DataGrid 导入与表设计器只读态 +- 后端 SQL/Mongo 查询判定与分类型写操作守卫 - 验收标准: -- 支持的数据源出现“生产连接/只允许查询”开关 -- 启用后普通查询仍可执行,非查询 SQL / Mongo 写命令被前后端阻止 -- 导入、结构变更、清表、同步目标等关键写入口被后端拒绝 +- 支持的数据源出现可多选的“生产连接保护”限制项 +- 勾选“限制脚本执行”后,普通查询仍可执行,非查询 SQL / Mongo 写命令被前后端阻止 +- 勾选“限制数据编辑 / 结构编辑 / 数据导入”后,对应 UI 与后端入口分别按类别拒绝 - 依赖与约束: - 保持现有数据源能力判定与 QueryEditor 执行链路 - MongoDB 前端判定以保守拦截为主,最终正确性由后端守卫兜底 ## 3. 里程碑与进度 -- [x] 阶段 1(需求澄清):确认采用连接级 `readOnly` 布尔字段,不新建环境系统 +- [x] 阶段 1(需求澄清):确认采用多限制项保护,而不是单一 `readOnly` 开关 - [x] 阶段 2(影响分析):梳理前端能力面板、查询执行、导入与后端写入口 - [x] 阶段 3(方案设计):确定“前端预拦截 + 后端最终守卫”双层保护 - [x] 阶段 4(实施计划):接入配置链路、能力判定、查询判定与写入口守卫 @@ -31,21 +31,23 @@ ## 4. 变更清单 - 已完成: -- 新增连接级 `readOnly` 配置字段及前后端序列化支持 -- 连接弹窗为 SQL 类数据库与 MongoDB 增加生产连接保护开关 -- QueryEditor 增加本地非查询拦截,DataGrid 导入入口在只读连接下禁用 -- 后端为查询、DDL、导入、清表、同步等写入口增加统一只读守卫 +- 新增连接级 `protection` 多限制项配置,并保留旧 `readOnly` 兼容映射 +- 连接弹窗为 SQL 类数据库与 MongoDB 改为 DBeaver 风格的生产连接保护多选项 +- 修复生产连接保护限制项卡片的 checkbox 对齐与选中态漂移问题,并保留整卡点击切换 +- QueryEditor 改为仅在“限制脚本执行”下拦截非查询 SQL / Mongo 写命令 +- DataGrid / TableDesigner / Sidebar 能力分别按数据编辑、结构编辑、数据导入限制收口 +- 后端为查询、DDL、导入、清表、同步等写入口增加分类型守卫 - MongoDB 查询判定改为命令级白名单,不再把所有 JSON 命令都视为只读 - 补充前端/后端定向测试与需求追踪文档 - 进行中: -- 等待体验包验证连接弹窗、查询拦截和写操作拒绝文案 +- 等待体验包验证多限制项 UI、分类型限制是否与实际入口完全一致 - 待处理: - 如需进一步优化体验,再补侧栏对象级写菜单的前端隐藏/禁用 ## 5. 风险与阻塞 - 风险: -- 前端 SQL/Mongo 只读判定是保守策略,边界命令可能仍需后端兜底 -- 现有部分侧栏写菜单仍可能显示,但执行时会被后端拒绝 +- 前端 SQL/Mongo 脚本判定是保守策略,边界命令仍需后端兜底 +- 现有部分侧栏写菜单仍可能显示,但会按新的结构编辑限制在执行时被后端拒绝 - 阻塞: - 暂无 - 缓解措施: @@ -53,22 +55,25 @@ ## 6. 决策记录 - 决策 1:只对 SQL 类数据库和 MongoDB 支持连接级生产保护,其他数据源忽略 `readOnly` -- 决策 2:采用顶层 `readOnly` 布尔字段,避免新增环境枚举和迁移成本 -- 决策 3:MongoDB 只读判定按命令白名单处理,防止把写命令误放行 +- 决策 2:新增 `protection` 多限制项配置,旧 `readOnly=true` 自动映射为四项全开 +- 决策 3:脚本执行、数据编辑、结构编辑、数据导入分别在不同前后端入口生效 +- 决策 4:MongoDB 只读判定按命令白名单处理,防止把写命令误放行 ## 7. 验证记录 - 验证项: - 前端数据源能力判定、RPC 配置、连接配置测试 -- 后端只读连接守卫与 SQL/Mongo 查询判定测试 +- 后端生产保护守卫与 SQL/Mongo 查询判定测试 - 结果: - 通过 - 证据(日志/截图/链接): -- `go test ./internal/app -run 'Test(EnsureReadOnlyConnectionAllows|SupportsConnectionReadOnlyMode|IsReadOnlySQLQuery)'` -- `npm --prefix frontend test -- src/utils/dataSourceCapabilities.test.ts src/utils/connectionRpcConfig.test.ts src/components/connectionModal/connectionModalConfig.keepalive.test.ts` +- `go test ./internal/app -run 'TestEnsureReadOnlyConnectionAllowsQuery|TestEnsureReadOnlyConnectionAllowsAction|TestEnsureConnectionProtectionSeparatesActionCategories'` +- `npm --prefix frontend run build` +- `npm --prefix frontend test -- src/utils/connectionReadOnly.test.ts src/utils/dataSourceCapabilities.test.ts src/i18n/i18n.test.ts src/components/ConnectionModal.i18n.test.tsx -t 'readOnly|production-guard'` +- 说明:`src/components/ConnectionModal.i18n.test.tsx` 全量仍有 2 条既有基线红测,与本次生产连接保护改动无关 ## 8. 下一步 - 下一步行动: -- 用体验包回归验证生产连接下的查询、导入、建库删库、同步目标和 Mongo 命令拦截 +- 用体验包回归验证四类限制项在查询、结果编辑、表设计、导入、同步目标和 Mongo 命令上的实际表现 - 如需更完整 UX,再补侧栏写菜单的只读态收口 - 负责人: - Codex diff --git a/frontend/src/components/ConnectionModal.i18n.test.tsx b/frontend/src/components/ConnectionModal.i18n.test.tsx index 0946948..684781e 100644 --- a/frontend/src/components/ConnectionModal.i18n.test.tsx +++ b/frontend/src/components/ConnectionModal.i18n.test.tsx @@ -459,6 +459,69 @@ describe("ConnectionModal i18n", () => { }, ); + it.each([ + { + language: "zh-CN" as const, + sourceLabel: "MySQL", + expectations: [ + "生产连接保护", + "按需勾选限制项", + "限制数据编辑", + "限制结构编辑", + "限制脚本执行", + "限制数据导入", + "当前策略", + ], + }, + { + language: "en-US" as const, + sourceLabel: "MySQL", + expectations: [ + "Production guard", + "Select only the restrictions you need", + "Restrict data edits", + "Restrict structure edits", + "Restrict script execution", + "Restrict data import", + "Current policy", + ], + }, + ])( + "renders a detailed production-guard card in $language", + async ({ language, sourceLabel, expectations }) => { + setCurrentLanguage(language); + storeState.languagePreference = language; + mockFormValues = { + type: "mysql", + restrictDataEdit: true, + restrictStructureEdit: true, + restrictScriptExecution: false, + restrictDataImport: false, + useSSL: true, + sslMode: "preferred", + timeout: 30, + }; + + const { default: ConnectionModal } = await import("./ConnectionModal"); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + findClickableCard(renderer!, sourceLabel).props.onClick(); + }); + + const pageText = textContent(renderer!.toJSON()); + expectations.forEach((expected) => { + expect(pageText).toContain(expected); + }); + expect(pageText).not.toContain("connection.modal.section.undefined.title"); + expect(pageText).not.toContain("connection.modal.section.undefined.description"); + }, + ); + it("renders English topology and authentication copy for legacy mysql, mongodb, and redis sections", async () => { storeState.appearance.uiVersion = "legacy"; setCurrentLanguage("en-US"); diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index d857d8a..63119a0 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -62,6 +62,7 @@ import { import { getCustomConnectionDsnValidationMessage } from "../utils/customConnectionDsn"; import { mergeParsedUriValuesForForm } from "../utils/connectionUriMerge"; import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig"; +import { resolveConnectionProtectionConfig } from "../utils/connectionReadOnly"; import { getCustomConnectionDriverHelp } from "../utils/driverImportGuidance"; import { isBackendCancelledResult } from "../utils/connectionExport"; import { @@ -1379,6 +1380,7 @@ const ConnectionModal: React.FC<{ : Number(config.timeout || 30); const hasHttpTunnel = !!config.useHttpTunnel; const hasProxy = !hasHttpTunnel && !!config.useProxy; + const protection = resolveConnectionProtectionConfig(config); form.setFieldsValue({ type: configType, name: initialValues.name, @@ -1387,7 +1389,11 @@ const ConnectionModal: React.FC<{ user: config.user, password: config.password, database: config.database, - readOnly: config.readOnly === true, + restrictDataEdit: protection.restrictDataEdit === true, + restrictStructureEdit: protection.restrictStructureEdit === true, + restrictScriptExecution: + protection.restrictScriptExecution === true, + restrictDataImport: protection.restrictDataImport === true, uri: config.uri || "", connectionParams: config.connectionParams || diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 980e89c..4d0b373 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -32,6 +32,7 @@ import { v4 as generateUuid } from 'uuid'; import 'react-resizable/css/styles.css'; import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; +import { isConnectionDataImportRestricted } from '../utils/connectionReadOnly'; import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol'; @@ -533,13 +534,15 @@ const DataGrid: React.FC = ({ const prefersManualTotalCount = dataSourceCaps.preferManualTotalCount; const supportsApproximateTableCount = dataSourceCaps.supportsApproximateTableCount; const supportsApproximateTotalPages = dataSourceCaps.supportsApproximateTotalPages; + const designerReadOnly = dataSourceCaps.forceReadOnlyStructureDesigner; + const importRestricted = isConnectionDataImportRestricted(currentConnConfig); const dbType = dataSourceCaps.type; const isMongoDBConnection = dbType === 'mongodb'; const isDuckDBConnection = dataSourceCaps.type === 'duckdb'; const supportsCopyInsert = dataSourceCaps.supportsCopyInsert; const supportsSqlQueryExport = dataSourceCaps.supportsSqlQueryExport; const isQueryResultExport = exportScope === 'queryResult'; - const canImport = exportScope === 'table' && !!tableName && !readOnly; + const canImport = exportScope === 'table' && !!tableName && !importRestricted; const canExport = !!connectionId && (isQueryResultExport || !!tableName); const canViewDdl = exportScope === 'table' && !!connectionId && !!tableName; const canOpenObjectDesigner = exportScope === 'table' && objectType === 'table' && !!connectionId && !!tableName; @@ -4176,6 +4179,7 @@ const DataGrid: React.FC = ({ copyRowsForPaste, copyToClipboard, currentConnConfig, + designerReadOnly, currentTextRow, darkMode, dataContextValue, diff --git a/frontend/src/components/DataGridShell.tsx b/frontend/src/components/DataGridShell.tsx index bb7f03e..8140cff 100644 --- a/frontend/src/components/DataGridShell.tsx +++ b/frontend/src/components/DataGridShell.tsx @@ -100,6 +100,7 @@ const DataGridShell: React.FC = (props) => { copyRowsForPaste, copyToClipboard, currentConnConfig, + designerReadOnly, currentTextRow, darkMode, dataContextValue, @@ -734,7 +735,7 @@ const renderDataTableView = () => ( dbName, tableName, initialTab: 'columns', - readOnly, + readOnly: designerReadOnly, objectType: 'table', }} /> diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 50e7f49..1c143c3 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -3116,6 +3116,48 @@ describe('QueryEditor external SQL save', () => { expect(tableSuggestion.detail).not.toContain('Table (analytics)'); }); + it('deduplicates Oracle-style database qualified table completion labels when schema matches the qualifier', async () => { + storeState.languagePreference = 'zh-CN'; + setCurrentLanguage('zh-CN'); + storeState.connections[0].config.type = 'oracle'; + storeState.connections[0].config.database = 'ORCLPDB1'; + editorState.value = 'select * from sbdev.AA'; + autoFetchState.visible = true; + backendApp.DBGetDatabases.mockResolvedValueOnce({ + success: true, + data: [{ Database: 'ORCLPDB1' }, { Database: 'sbdev' }], + }); + backendApp.DBGetTables.mockImplementation(async (_config: any, dbName: string) => { + if (String(dbName || '').toLowerCase() === 'sbdev') { + return { success: true, data: [{ Table: 'SBDEV.AAA3_NJ' }] }; + } + return { success: true, data: [] }; + }); + backendApp.DBGetAllColumns.mockResolvedValue({ success: true, data: [] }); + + await act(async () => { + create(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const completionProvider = editorState.providers[0]; + expect(completionProvider).toBeTruthy(); + + const completionItems = await completionProvider.provideCompletionItems( + editorState.editor.getModel(), + { lineNumber: 1, column: editorState.value.length + 1 }, + ); + const tableSuggestion = completionItems?.suggestions?.find((item: any) => item?.label === 'AAA3_NJ'); + + expect(tableSuggestion).toBeTruthy(); + expect(tableSuggestion.insertText).toBe('AAA3_NJ'); + expect(tableSuggestion.detail).toContain('表 (sbdev)'); + expect(completionItems?.suggestions?.some((item: any) => item?.label === 'sbdev.SBDEV.AAA3_NJ')).toBe(false); + }); + it('localizes schema-qualified table completion detail in zh-CN while preserving the raw database and schema names', async () => { storeState.languagePreference = 'zh-CN'; setCurrentLanguage('zh-CN'); diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 3758615..c619f77 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -191,6 +191,7 @@ let sharedMaterializedViewsData: CompletionViewMeta[] = []; let sharedTriggersData: CompletionTriggerMeta[] = []; let sharedRoutinesData: CompletionRoutineMeta[] = []; let sharedColumnsCacheData: Record = {}; +const QUERY_EDITOR_LAZY_VISIBLE_DB_COMPLETION_LIMIT = 10; const sharedLazyTablesCache: Record = {}; const sharedLazyTablesInFlight: Record | undefined> = {}; const createEmptySqlCompletionResult = () => ({ suggestions: [] as any[] }); @@ -204,6 +205,7 @@ const clearRecord = (record: Record) => { const resetSharedQueryEditorMetadata = () => { sharedTablesData = []; sharedAllColumnsData = []; + sharedVisibleDbs = []; sharedViewsData = []; sharedMaterializedViewsData = []; sharedTriggersData = []; @@ -1912,6 +1914,26 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const stripQuotes = stripCompletionIdentifierQuotes; const normalizeQualifiedName = normalizeCompletionQualifiedName; const splitSchemaAndTable = splitCompletionSchemaAndTable; + const buildDbQualifiedTableSuggestionMeta = (dbName: string, tableName: string) => { + const rawDbName = String(dbName || '').trim(); + const rawTableName = String(tableName || '').trim(); + const parsed = splitSchemaAndTable(rawTableName); + const schemaMatchesDb = !!parsed.schema + && !!parsed.table + && parsed.schema.toLowerCase() === rawDbName.toLowerCase(); + const displayName = schemaMatchesDb ? parsed.table : rawTableName; + const insertText = schemaMatchesDb + ? quoteCompletionPart(parsed.table) + : quoteCompletionPath(rawTableName); + const dbQualifiedLabel = rawDbName + ? `${rawDbName}.${displayName || rawTableName}` + : (displayName || rawTableName); + return { + displayName: displayName || rawTableName, + insertText, + dbQualifiedLabel, + }; + }; const buildConnConfig = () => { const connId = sharedCurrentConnectionId; @@ -2101,18 +2123,25 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } } const filtered = prefix - ? tables.filter(t => (t.tableName || '').toLowerCase().startsWith(prefix)) + ? tables.filter(t => { + const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || qualifier, t.tableName || ''); + return String(suggestionMeta.displayName || '').toLowerCase().startsWith(prefix) + || String(t.tableName || '').toLowerCase().startsWith(prefix); + }) : tables; - const suggestions = filtered.map(t => ({ - label: t.tableName, + const suggestions = filtered.map(t => { + const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || qualifier, t.tableName || ''); + return { + label: suggestionMeta.displayName, kind: monaco.languages.CompletionItemKind.Class, - insertText: quoteCompletionPath(t.tableName), + insertText: suggestionMeta.insertText, detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${t.dbName})`, t.comment), documentation: buildCompletionDocumentation(t.comment), range, - sortText: '0' + t.tableName - })); + sortText: '0' + suggestionMeta.displayName + }; + }); return { suggestions }; } @@ -2201,7 +2230,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc if (normalized.some((candidate) => candidate.includes(wordPrefix))) return '1'; return '9'; }; - const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix.trim()); + const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix); const shouldBoostKeywords = !expectsTableName && wordPrefix.length > 0 && dialectKeywords.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix)); @@ -2230,6 +2259,38 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }); } } + if (expectsTableName && sharedVisibleDbs.length > 1) { + const loadedDbKeys = new Set( + completionTables + .map((table) => String(table.dbName || '').toLowerCase()) + .filter(Boolean), + ); + const missingVisibleDbs = sharedVisibleDbs.filter((dbName) => { + const normalizedDbName = String(dbName || '').trim(); + const dbKey = normalizedDbName.toLowerCase(); + return normalizedDbName + && dbKey !== currentDatabase.toLowerCase() + && !loadedDbKeys.has(dbKey); + }); + if ( + missingVisibleDbs.length > 0 + && missingVisibleDbs.length <= QUERY_EDITOR_LAZY_VISIBLE_DB_COMPLETION_LIMIT + ) { + const lazyTableGroups = await Promise.all( + missingVisibleDbs.map((dbName) => getLazyTablesByDB(dbName)), + ); + if (isSqlCompletionRequestCancelled(token)) { + return createEmptySqlCompletionResult(); + } + const seenTableKeys = new Set(); + completionTables = [...completionTables, ...lazyTableGroups.flat()].filter((table) => { + const key = `${String(table.dbName || '').toLowerCase()}.${String(table.tableName || '').toLowerCase()}`; + if (seenTableKeys.has(key)) return false; + seenTableKeys.add(key); + return true; + }); + } + } const referencedColumns: CompletionColumnMeta[] = []; if (!expectsTableName) { @@ -2296,9 +2357,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); const parsed = splitSchemaAndTable(t.tableName || ''); const pureTable = parsed.table || t.tableName || ''; - if (!isCurrentDb) { - // 跨库:用 db.table 格式匹配 - return includesWordPrefix(`${t.dbName}.${t.tableName}`) + if (!isCurrentDb) { + const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || '', t.tableName || ''); + const label = suggestionMeta.dbQualifiedLabel; + // 跨库:用 db.table 格式匹配 + return includesWordPrefix(label) || includesWordPrefix(t.tableName || '') || includesWordPrefix(pureTable); } @@ -2310,7 +2373,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const parsed = splitSchemaAndTable(t.tableName || ''); const pureTable = parsed.table || t.tableName || ''; if (!isCurrentDb) { - const label = `${t.dbName}.${t.tableName}`; + const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || '', t.tableName || ''); + const label = suggestionMeta.dbQualifiedLabel; return { label, kind: monaco.languages.CompletionItemKind.Class, @@ -2318,7 +2382,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${t.dbName})`, t.comment), documentation: buildCompletionDocumentation(t.comment), range, - sortText: sortGroups.tableOther + getPrefixMatchRank(`${t.dbName}.${t.tableName}`, t.tableName || '', pureTable) + t.tableName, + sortText: sortGroups.tableOther + getPrefixMatchRank(label, t.tableName || '', pureTable) + label, }; } // 当前库:检查是否有跨 schema 同名表 diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 76c2381..d95b99e 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -119,6 +119,7 @@ import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import FindInDatabaseModal from './FindInDatabaseModal'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { resolveDataSourceType } from '../utils/dataSourceCapabilities'; +import { isConnectionStructureEditRestricted } from '../utils/connectionReadOnly'; import { noAutoCapInputProps } from '../utils/inputAutoCap'; import { resolveSidebarRuntimeDatabase, @@ -1412,7 +1413,10 @@ const Sidebar: React.FC<{ const openDesign = (node: any, initialTab: string, readOnly: boolean = false) => { const { tableName, dbName, id } = node.dataRef; - const forceReadOnly = readOnly || isStructureOnlyDbType(id); + const conn = connections.find(c => c.id === id); + const forceReadOnly = readOnly + || isStructureOnlyDbType(id) + || isConnectionStructureEditRestricted(conn?.config); addTab({ id: `design-${id}-${dbName}-${tableName}`, title: forceReadOnly @@ -1429,7 +1433,8 @@ const Sidebar: React.FC<{ const openNewTableDesign = (node: any) => { const { dbName, id } = node.dataRef; - if (isStructureOnlyDbType(id)) { + const conn = connections.find(c => c.id === id); + if (isStructureOnlyDbType(id) || isConnectionStructureEditRestricted(conn?.config)) { message.warning(t('sidebar.message.visual_new_table_unsupported')); return; } diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index f2f9a50..fbbe9f0 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -26,6 +26,7 @@ import { isMacLikePlatform } from '../utils/appearance'; import { getShortcutPlatform } from '../utils/shortcuts'; import { t } from '../i18n'; import { buildTableExportTab } from '../utils/tableExportTab'; +import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; import { V2TableContextMenuView, type V2TableContextMenuActionKey } from './V2TableContextMenu'; import { useExportProgressDialog } from './ExportProgressModal'; @@ -279,7 +280,7 @@ const TableOverview: React.FC = ({ tab }) => { [connection?.config?.driver, connection?.config?.oceanBaseProtocol, connection?.config?.type] ); const schemaName = String((tab as any).schemaName || '').trim(); - const supportsDesignWrite = metadataDialect !== 'iotdb'; + const supportsDesignWrite = !getDataSourceCapabilities(connection?.config).forceReadOnlyStructureDesigner; const autoFetchVisible = useAutoFetchVisibility(); const loadData = useCallback(async () => { diff --git a/frontend/src/components/connectionModal/ConnectionModalStep2.tsx b/frontend/src/components/connectionModal/ConnectionModalStep2.tsx index 991f20e..33b56ff 100644 --- a/frontend/src/components/connectionModal/ConnectionModalStep2.tsx +++ b/frontend/src/components/connectionModal/ConnectionModalStep2.tsx @@ -37,7 +37,9 @@ import { import ConnectionModalMongoSections from "../ConnectionModalMongoSections"; import ConnectionModalRedisSections from "../ConnectionModalRedisSections"; import { t } from "../../i18n"; -import { supportsConnectionReadOnlyMode } from "../../utils/connectionReadOnly"; +import { + supportsConnectionReadOnlyMode, +} from "../../utils/connectionReadOnly"; import { getConnectionConfigLayoutKindLabel, getStoredSecretPlaceholder, @@ -198,6 +200,19 @@ const renderStep2 = () => { driver: form.getFieldValue("driver"), oceanBaseProtocol, }); + const restrictDataEdit = Form.useWatch("restrictDataEdit", form) === true; + const restrictStructureEdit = + Form.useWatch("restrictStructureEdit", form) === true; + const restrictScriptExecution = + Form.useWatch("restrictScriptExecution", form) === true; + const restrictDataImport = + Form.useWatch("restrictDataImport", form) === true; + const connectionProtectionEnabledCount = [ + restrictDataEdit, + restrictStructureEdit, + restrictScriptExecution, + restrictDataImport, + ].filter(Boolean).length; const baseInfoSection = (
{ renderConfigSectionCard({ sectionKey: "readOnly", icon: , - children: ( - 0 ? "red" : "default" + } > - clearConnectionTestResultForChoice()} + {connectionProtectionEnabledCount > 0 + ? t( + "connection.modal.field.readOnly.status.enabledCount", + { + count: connectionProtectionEnabledCount, + }, + ) + : t("connection.modal.field.readOnly.status.disabled")} + + ), + children: ( +
+
0 + ? darkMode + ? "1px solid rgba(255,120,117,0.34)" + : "1px solid rgba(245,34,45,0.18)" + : darkMode + ? "1px solid rgba(255,214,102,0.24)" + : "1px solid rgba(250,173,20,0.18)", + background: connectionProtectionEnabledCount > 0 + ? darkMode + ? "linear-gradient(180deg, rgba(255,120,117,0.12) 0%, rgba(255,120,117,0.05) 100%)" + : "linear-gradient(180deg, rgba(255,245,245,0.96) 0%, rgba(255,240,240,0.92) 100%)" + : darkMode + ? "linear-gradient(180deg, rgba(255,214,102,0.10) 0%, rgba(255,214,102,0.04) 100%)" + : "linear-gradient(180deg, rgba(255,251,230,0.98) 0%, rgba(255,247,214,0.94) 100%)", + boxShadow: darkMode + ? "inset 0 1px 0 rgba(255,255,255,0.04)" + : "inset 0 1px 0 rgba(255,255,255,0.92)", + }} > - {t("connection.modal.field.readOnly.checkbox")} - - +
+
+
+ {t("connection.modal.field.readOnly.label")} +
+
+ {t("connection.modal.field.readOnly.help")} +
+
+ + {t("connection.modal.field.readOnly.compatibility")} + +
+
+
+ {[ + { + field: "restrictDataEdit", + checked: restrictDataEdit, + label: t( + "connection.modal.field.readOnly.option.dataEdit.label", + ), + help: t( + "connection.modal.field.readOnly.option.dataEdit.help", + ), + }, + { + field: "restrictStructureEdit", + checked: restrictStructureEdit, + label: t( + "connection.modal.field.readOnly.option.structureEdit.label", + ), + help: t( + "connection.modal.field.readOnly.option.structureEdit.help", + ), + }, + { + field: "restrictScriptExecution", + checked: restrictScriptExecution, + label: t( + "connection.modal.field.readOnly.option.scriptExecution.label", + ), + help: t( + "connection.modal.field.readOnly.option.scriptExecution.help", + ), + }, + { + field: "restrictDataImport", + checked: restrictDataImport, + label: t( + "connection.modal.field.readOnly.option.dataImport.label", + ), + help: t( + "connection.modal.field.readOnly.option.dataImport.help", + ), + }, + ].map((item) => ( +
+ setChoiceFieldValue(item.field, !item.checked) + } + style={{ + padding: 14, + borderRadius: 14, + border: item.checked + ? darkMode + ? "1px solid rgba(255,120,117,0.22)" + : "1px solid rgba(245,34,45,0.14)" + : darkMode + ? "1px solid rgba(255,255,255,0.08)" + : "1px solid rgba(5,5,5,0.08)", + background: item.checked + ? darkMode + ? "rgba(255,120,117,0.08)" + : "rgba(255,241,240,0.92)" + : darkMode + ? "rgba(255,255,255,0.02)" + : "rgba(255,255,255,0.9)", + cursor: "pointer", + }} + > +
+
event.stopPropagation()} + > + + + clearConnectionTestResultForChoice() + } + style={{ + marginInlineStart: 0, + }} + /> + +
+
+
+ {item.label} +
+
+ {item.help} +
+
+
+
+ ))} +
+
+
+ {t("connection.modal.field.readOnly.summary.title")} +
+
+ {connectionProtectionEnabledCount > 0 + ? t( + "connection.modal.field.readOnly.summary.selected", + { count: connectionProtectionEnabledCount }, + ) + : t( + "connection.modal.field.readOnly.summary.empty", + )} +
+
+
+ {t("connection.modal.field.readOnly.tip")} +
+
), })} @@ -2333,7 +2580,10 @@ const renderStep2 = () => { keepAliveIntervalMinutes: 240, uri: "", connectionParams: "", - readOnly: false, + restrictDataEdit: false, + restrictStructureEdit: false, + restrictScriptExecution: false, + restrictDataImport: false, oceanBaseProtocol: "mysql", mysqlTopology: "single", rocketmqTopology: "single", diff --git a/frontend/src/components/connectionModal/connectionModalConfig.ts b/frontend/src/components/connectionModal/connectionModalConfig.ts index 4099ddc..eabfc81 100644 --- a/frontend/src/components/connectionModal/connectionModalConfig.ts +++ b/frontend/src/components/connectionModal/connectionModalConfig.ts @@ -1,5 +1,9 @@ import type { ConnectionConfig, SavedConnection } from "../../types"; -import { supportsConnectionReadOnlyMode } from "../../utils/connectionReadOnly"; +import { + deriveLegacyConnectionReadOnlyFlag, + normalizeConnectionProtectionConfig, + supportsConnectionReadOnlyMode, +} from "../../utils/connectionReadOnly"; import { resolveConnectionSecretDraft } from "../../utils/connectionSecretDraft"; import { getConnectionTypeDefaultPort as getDefaultPortByType, @@ -797,6 +801,19 @@ export const buildConnectionConfig = async ({ ) : normalizeConnectionParamsText(mergedValues.connectionParams) : ""; + const supportsProductionGuard = supportsConnectionReadOnlyMode({ + type, + driver: mergedValues.driver, + oceanBaseProtocol: selectedOceanBaseProtocol, + }); + const protection = supportsProductionGuard + ? normalizeConnectionProtectionConfig({ + restrictDataEdit: mergedValues.restrictDataEdit === true, + restrictStructureEdit: mergedValues.restrictStructureEdit === true, + restrictScriptExecution: mergedValues.restrictScriptExecution === true, + restrictDataImport: mergedValues.restrictDataImport === true, + }) + : undefined; return { type: mergedValues.type, @@ -806,12 +823,10 @@ export const buildConnectionConfig = async ({ password: keepPassword ? mergedValues.password || "" : "", savePassword: savePassword, database: mergedValues.database || "", - readOnly: - supportsConnectionReadOnlyMode({ - type, - driver: mergedValues.driver, - oceanBaseProtocol: selectedOceanBaseProtocol, - }) && mergedValues.readOnly === true, + readOnly: protection + ? deriveLegacyConnectionReadOnlyFlag(protection) + : false, + protection, useSSL: effectiveUseSSL, sslMode: effectiveUseSSL ? sslMode : "disable", sslCAPath: sslCAPath, diff --git a/frontend/src/i18n/i18n.test.ts b/frontend/src/i18n/i18n.test.ts index ca8f96e..4244f37 100644 --- a/frontend/src/i18n/i18n.test.ts +++ b/frontend/src/i18n/i18n.test.ts @@ -93,6 +93,76 @@ const remainingConnectionModalSliceExpectations: LocalizedExpectation[] = [ key: "connection.modal.field.dsn.placeholder", catalogKey: "connection_modal.field.dsn.placeholder", }, + { + key: "connection.modal.section.readOnly.title", + catalogKey: "connection_modal.section.readOnly.title", + }, + { + key: "connection.modal.section.readOnly.description", + catalogKey: "connection_modal.section.readOnly.description", + }, + { + key: "connection.modal.field.readOnly.label", + catalogKey: "connection_modal.field.readOnly.label", + }, + { + key: "connection.modal.field.readOnly.help", + catalogKey: "connection_modal.field.readOnly.help", + }, + { + key: "connection.modal.field.readOnly.status.enabledCount", + catalogKey: "connection_modal.field.readOnly.status.enabledCount", + params: { count: "2" }, + }, + { + key: "connection.modal.field.readOnly.compatibility", + catalogKey: "connection_modal.field.readOnly.compatibility", + }, + { + key: "connection.modal.field.readOnly.option.dataEdit.label", + catalogKey: "connection_modal.field.readOnly.option.dataEdit.label", + }, + { + key: "connection.modal.field.readOnly.option.dataEdit.help", + catalogKey: "connection_modal.field.readOnly.option.dataEdit.help", + }, + { + key: "connection.modal.field.readOnly.option.structureEdit.label", + catalogKey: "connection_modal.field.readOnly.option.structureEdit.label", + }, + { + key: "connection.modal.field.readOnly.option.structureEdit.help", + catalogKey: "connection_modal.field.readOnly.option.structureEdit.help", + }, + { + key: "connection.modal.field.readOnly.option.scriptExecution.label", + catalogKey: "connection_modal.field.readOnly.option.scriptExecution.label", + }, + { + key: "connection.modal.field.readOnly.option.scriptExecution.help", + catalogKey: "connection_modal.field.readOnly.option.scriptExecution.help", + }, + { + key: "connection.modal.field.readOnly.option.dataImport.label", + catalogKey: "connection_modal.field.readOnly.option.dataImport.label", + }, + { + key: "connection.modal.field.readOnly.option.dataImport.help", + catalogKey: "connection_modal.field.readOnly.option.dataImport.help", + }, + { + key: "connection.modal.field.readOnly.summary.title", + catalogKey: "connection_modal.field.readOnly.summary.title", + }, + { + key: "connection.modal.field.readOnly.summary.selected", + catalogKey: "connection_modal.field.readOnly.summary.selected", + params: { count: "2" }, + }, + { + key: "connection.modal.field.readOnly.summary.empty", + catalogKey: "connection_modal.field.readOnly.summary.empty", + }, { key: "driver.guidance.customConnectionDriverHelp", catalogKey: "driver.guidance.customConnectionDriverHelp", diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 8fb6b24..ca31bf3 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -73,6 +73,11 @@ import { saveSavedQueryToBackend, type SavedQueryBackend, } from "./utils/savedQueryPersistence"; +import { + deriveLegacyConnectionReadOnlyFlag, + normalizeConnectionProtectionConfig, + resolveConnectionProtectionConfig, +} from "./utils/connectionReadOnly"; export interface AppearanceSettings extends DataGridDisplaySettings { uiVersion: "legacy" | "v2"; @@ -789,6 +794,9 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { supportsNetworkTunnel && (raw.useHttpTunnel === true || raw.UseHTTPTunnel === true); const useProxy = supportsNetworkTunnel && !!raw.useProxy && !useHttpTunnel; + const normalizedProtection = normalizeConnectionProtectionConfig( + raw.protection, + ); const safeConfig: ConnectionConfig & Record = { ...raw, @@ -801,6 +809,7 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { savePassword, database: toTrimmedString(raw.database), readOnly: raw.readOnly === true, + protection: normalizedProtection, useSSL: sslCapable ? !!raw.useSSL : false, sslMode: sslCapable ? sslMode : "disable", sslCAPath: sslCapable ? toTrimmedString(raw.sslCAPath) : "", @@ -854,6 +863,10 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { ), }; + const resolvedProtection = resolveConnectionProtectionConfig(safeConfig); + safeConfig.protection = resolvedProtection; + safeConfig.readOnly = deriveLegacyConnectionReadOnlyFlag(resolvedProtection); + if (type === "redis") { safeConfig.redisDB = normalizeIntegerInRange( raw.redisDB, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index e81e6f3..a88574b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -21,6 +21,13 @@ export interface HTTPTunnelConfig { password?: string; } +export interface ConnectionProtectionConfig { + restrictDataEdit?: boolean; + restrictStructureEdit?: boolean; + restrictScriptExecution?: boolean; + restrictDataImport?: boolean; +} + export interface JVMJMXConfig { enabled?: boolean; host?: string; @@ -283,6 +290,7 @@ export interface ConnectionConfig { savePassword?: boolean; database?: string; readOnly?: boolean; + protection?: ConnectionProtectionConfig; useSSL?: boolean; sslMode?: "preferred" | "required" | "skip-verify" | "disable"; sslCAPath?: string; diff --git a/frontend/src/utils/connectionModalPresentation.test.ts b/frontend/src/utils/connectionModalPresentation.test.ts index 38b280f..e467cca 100644 --- a/frontend/src/utils/connectionModalPresentation.test.ts +++ b/frontend/src/utils/connectionModalPresentation.test.ts @@ -16,6 +16,7 @@ const sectionKeyEntries = [ ['uri', 'uri'], ['target', 'target'], ['fileTarget', 'fileTarget'], + ['readOnly', 'readOnly'], ['connectionMode', 'connectionMode'], ['oceanBaseProtocol', 'oceanBaseProtocol'], ['mongoDiscovery', 'mongoDiscovery'], diff --git a/frontend/src/utils/connectionModalPresentation.ts b/frontend/src/utils/connectionModalPresentation.ts index 65b8c14..32c020f 100644 --- a/frontend/src/utils/connectionModalPresentation.ts +++ b/frontend/src/utils/connectionModalPresentation.ts @@ -22,6 +22,7 @@ export type ConnectionConfigSectionKey = | 'uri' | 'target' | 'fileTarget' + | 'readOnly' | 'connectionMode' | 'oceanBaseProtocol' | 'mongoDiscovery' @@ -86,6 +87,7 @@ const connectionConfigSectionKeyMap: Record< uri: 'uri', target: 'target', fileTarget: 'fileTarget', + readOnly: 'readOnly', connectionMode: 'connectionMode', oceanBaseProtocol: 'oceanBaseProtocol', mongoDiscovery: 'mongoDiscovery', diff --git a/frontend/src/utils/connectionReadOnly.test.ts b/frontend/src/utils/connectionReadOnly.test.ts new file mode 100644 index 0000000..fe35136 --- /dev/null +++ b/frontend/src/utils/connectionReadOnly.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { + findConnectionMutatingStatements, + isConnectionDataEditRestricted, + isConnectionDataImportRestricted, + isConnectionScriptExecutionRestricted, + isConnectionStructureEditRestricted, + resolveConnectionProtectionConfig, +} from './connectionReadOnly'; + +describe('connectionReadOnly', () => { + it('maps legacy readOnly connections to the full production protection set', () => { + expect(resolveConnectionProtectionConfig({ + type: 'postgres', + readOnly: true, + })).toEqual({ + restrictDataEdit: true, + restrictStructureEdit: true, + restrictScriptExecution: true, + restrictDataImport: true, + }); + }); + + it('keeps partial protection flags isolated from each other', () => { + const config = { + type: 'postgres', + protection: { + restrictDataEdit: true, + restrictDataImport: true, + }, + }; + + expect(isConnectionDataEditRestricted(config)).toBe(true); + expect(isConnectionDataImportRestricted(config)).toBe(true); + expect(isConnectionStructureEditRestricted(config)).toBe(false); + expect(isConnectionScriptExecutionRestricted(config)).toBe(false); + }); + + it('only blocks mutating SQL when script execution protection is enabled', () => { + expect(findConnectionMutatingStatements({ + type: 'postgres', + protection: { + restrictScriptExecution: true, + }, + }, "SELECT * FROM users; UPDATE users SET name = 'next';")).toEqual([ + "UPDATE users SET name = 'next'", + ]); + + expect(findConnectionMutatingStatements({ + type: 'postgres', + protection: { + restrictDataEdit: true, + }, + }, "UPDATE users SET name = 'next';")).toEqual([]); + }); +}); diff --git a/frontend/src/utils/connectionReadOnly.ts b/frontend/src/utils/connectionReadOnly.ts index 3703130..cbc519f 100644 --- a/frontend/src/utils/connectionReadOnly.ts +++ b/frontend/src/utils/connectionReadOnly.ts @@ -3,11 +3,42 @@ import { convertMongoShellToJsonCommand } from "./mongodb"; import { resolveSqlDialect } from "./sqlDialect"; import { findSqlStatementRanges } from "./sqlStatementSelection"; +export type ConnectionProtectionKey = + | "restrictDataEdit" + | "restrictStructureEdit" + | "restrictScriptExecution" + | "restrictDataImport"; + +export type ConnectionProtectionConfig = NonNullable< + ConnectionConfig["protection"] +>; + type ConnectionReadOnlyLike = Pick< ConnectionConfig, - "type" | "driver" | "oceanBaseProtocol" | "readOnly" + "type" | "driver" | "oceanBaseProtocol" | "readOnly" | "protection" > | null | undefined; +export const CONNECTION_PROTECTION_KEYS: ConnectionProtectionKey[] = [ + "restrictDataEdit", + "restrictStructureEdit", + "restrictScriptExecution", + "restrictDataImport", +]; + +const EMPTY_CONNECTION_PROTECTION: ConnectionProtectionConfig = { + restrictDataEdit: false, + restrictStructureEdit: false, + restrictScriptExecution: false, + restrictDataImport: false, +}; + +const FULL_CONNECTION_PROTECTION: ConnectionProtectionConfig = { + restrictDataEdit: true, + restrictStructureEdit: true, + restrictScriptExecution: true, + restrictDataImport: true, +}; + const CONNECTION_READ_ONLY_TYPES = new Set([ "mysql", "goldendb", @@ -230,17 +261,109 @@ export const supportsConnectionReadOnlyMode = ( return CONNECTION_READ_ONLY_TYPES.has(resolveConnectionReadOnlyType(config)); }; +export const normalizeConnectionProtectionConfig = ( + value: unknown, +): ConnectionProtectionConfig => { + const raw = + value && typeof value === "object" + ? (value as Record) + : {}; + return { + restrictDataEdit: raw.restrictDataEdit === true, + restrictStructureEdit: raw.restrictStructureEdit === true, + restrictScriptExecution: raw.restrictScriptExecution === true, + restrictDataImport: raw.restrictDataImport === true, + }; +}; + +export const createEmptyConnectionProtectionConfig = + (): ConnectionProtectionConfig => ({ ...EMPTY_CONNECTION_PROTECTION }); + +export const deriveLegacyConnectionReadOnlyFlag = ( + protection: unknown, +): boolean => { + const normalized = normalizeConnectionProtectionConfig(protection); + return CONNECTION_PROTECTION_KEYS.every((key) => normalized[key] === true); +}; + +export const resolveConnectionProtectionConfig = ( + config: ConnectionReadOnlyLike, +): ConnectionProtectionConfig => { + if (!supportsConnectionReadOnlyMode(config)) { + return createEmptyConnectionProtectionConfig(); + } + const normalized = normalizeConnectionProtectionConfig(config?.protection); + const hasExplicitRestriction = CONNECTION_PROTECTION_KEYS.some( + (key) => normalized[key] === true, + ); + if (hasExplicitRestriction) { + return normalized; + } + if (config?.readOnly === true) { + return { ...FULL_CONNECTION_PROTECTION }; + } + return createEmptyConnectionProtectionConfig(); +}; + +export const isConnectionProtectionEnabled = ( + config: ConnectionReadOnlyLike, + key: ConnectionProtectionKey, +): boolean => { + return resolveConnectionProtectionConfig(config)[key] === true; +}; + +export const getConnectionProtectionEnabledCount = ( + config: ConnectionReadOnlyLike, +): number => { + const protection = resolveConnectionProtectionConfig(config); + return CONNECTION_PROTECTION_KEYS.filter( + (key) => protection[key] === true, + ).length; +}; + +export const hasAnyConnectionProtection = ( + config: ConnectionReadOnlyLike, +): boolean => { + return getConnectionProtectionEnabledCount(config) > 0; +}; + +export const isConnectionDataEditRestricted = ( + config: ConnectionReadOnlyLike, +): boolean => { + return isConnectionProtectionEnabled(config, "restrictDataEdit"); +}; + +export const isConnectionStructureEditRestricted = ( + config: ConnectionReadOnlyLike, +): boolean => { + return isConnectionProtectionEnabled(config, "restrictStructureEdit"); +}; + +export const isConnectionScriptExecutionRestricted = ( + config: ConnectionReadOnlyLike, +): boolean => { + return isConnectionProtectionEnabled(config, "restrictScriptExecution"); +}; + +export const isConnectionDataImportRestricted = ( + config: ConnectionReadOnlyLike, +): boolean => { + return isConnectionProtectionEnabled(config, "restrictDataImport"); +}; + export const isConnectionForcedReadOnly = ( config: ConnectionReadOnlyLike, ): boolean => { - return supportsConnectionReadOnlyMode(config) && config?.readOnly === true; + return deriveLegacyConnectionReadOnlyFlag( + resolveConnectionProtectionConfig(config), + ); }; export const findConnectionMutatingStatements = ( config: ConnectionReadOnlyLike, sql: string, ): string[] => { - if (!isConnectionForcedReadOnly(config)) { + if (!isConnectionScriptExecutionRestricted(config)) { return []; } return findSqlStatementRanges(String(sql || "")) diff --git a/frontend/src/utils/connectionRpcConfig.ts b/frontend/src/utils/connectionRpcConfig.ts index f5e8046..ba77496 100644 --- a/frontend/src/utils/connectionRpcConfig.ts +++ b/frontend/src/utils/connectionRpcConfig.ts @@ -1,4 +1,8 @@ import { connection } from '../../wailsjs/go/models'; +import { + deriveLegacyConnectionReadOnlyFlag, + normalizeConnectionProtectionConfig, +} from './connectionReadOnly'; import { OCEANBASE_PROTOCOL_PARAM_KEYS, resolveOceanBaseProtocolFromConfig, @@ -120,6 +124,7 @@ export function buildRpcConnectionConfig( const baseId = toStringValue(config.id).trim() || toStringValue(overrides.id).trim() || undefined; const timeout = toOptionalInteger(rpcMerged.timeout, toOptionalInteger(config.timeout)); const redisDB = toOptionalInteger(rpcMerged.redisDB, toOptionalInteger(config.redisDB)); + const protection = normalizeConnectionProtectionConfig(rpcMerged.protection); const rpcConfig = new connection.ConnectionConfig({ ...rpcPayload, @@ -129,7 +134,8 @@ export function buildRpcConnectionConfig( user: toStringValue(rpcMerged.user), password: toStringValue(rpcMerged.password), database: toStringValue(rpcMerged.database), - readOnly: rpcMerged.readOnly === true, + readOnly: deriveLegacyConnectionReadOnlyFlag(protection), + protection: new connection.ConnectionProtectionConfig(protection), useSSH: rpcMerged.useSSH === true, ssh: normalizeSSHConfig(rpcMerged.ssh), useProxy: rpcMerged.useProxy === true, diff --git a/frontend/src/utils/dataSourceCapabilities.test.ts b/frontend/src/utils/dataSourceCapabilities.test.ts index 2d1720b..edbfb0a 100644 --- a/frontend/src/utils/dataSourceCapabilities.test.ts +++ b/frontend/src/utils/dataSourceCapabilities.test.ts @@ -248,6 +248,39 @@ describe('dataSourceCapabilities', () => { supportsDropDatabase: false, supportsMessagePublish: false, forceReadOnlyQueryResult: true, + forceReadOnlyStructureDesigner: true, + }); + }); + + it('allows script execution while still disabling result edits when only data-edit protection is enabled', () => { + expect(getDataSourceCapabilities({ + type: 'postgres', + protection: { + restrictDataEdit: true, + }, + })).toMatchObject({ + type: 'postgres', + supportsCreateDatabase: true, + supportsRenameDatabase: true, + supportsDropDatabase: true, + forceReadOnlyQueryResult: true, + forceReadOnlyStructureDesigner: false, + }); + }); + + it('keeps query results editable while disabling DDL shortcuts when only structure protection is enabled', () => { + expect(getDataSourceCapabilities({ + type: 'postgres', + protection: { + restrictStructureEdit: true, + }, + })).toMatchObject({ + type: 'postgres', + supportsCreateDatabase: false, + supportsRenameDatabase: false, + supportsDropDatabase: false, + forceReadOnlyQueryResult: false, + forceReadOnlyStructureDesigner: true, }); }); @@ -258,6 +291,7 @@ describe('dataSourceCapabilities', () => { supportsCreateDatabase: false, supportsDropDatabase: false, forceReadOnlyQueryResult: false, + forceReadOnlyStructureDesigner: true, }); }); diff --git a/frontend/src/utils/dataSourceCapabilities.ts b/frontend/src/utils/dataSourceCapabilities.ts index 915c203..9f67d49 100644 --- a/frontend/src/utils/dataSourceCapabilities.ts +++ b/frontend/src/utils/dataSourceCapabilities.ts @@ -1,8 +1,14 @@ import type { ConnectionConfig } from '../types'; -import { isConnectionForcedReadOnly } from './connectionReadOnly'; +import { + isConnectionDataEditRestricted, + isConnectionStructureEditRestricted, +} from './connectionReadOnly'; import { normalizeOceanBaseProtocol } from './oceanBaseProtocol'; -type ConnectionLike = Pick | null | undefined; +type ConnectionLike = Pick< + ConnectionConfig, + 'type' | 'driver' | 'oceanBaseProtocol' | 'readOnly' | 'protection' +> | null | undefined; const normalizeDataSourceToken = (raw: string): string => { const normalized = String(raw || '').trim().toLowerCase(); @@ -142,6 +148,7 @@ const COPY_INSERT_TYPES = new Set([ const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']); const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'iotdb', 'clickhouse', 'rocketmq', 'mqtt', 'kafka', 'rabbitmq']); +const FORCE_READ_ONLY_STRUCTURE_DESIGNER_TYPES = new Set(['elasticsearch', 'mongodb', 'redis', 'iotdb']); const MESSAGE_PUBLISH_TYPES = new Set(['rocketmq', 'mqtt', 'kafka', 'rabbitmq']); const MANUAL_TOTAL_COUNT_TYPES = new Set(['duckdb', 'oracle', 'rocketmq', 'mqtt']); const APPROXIMATE_TABLE_COUNT_TYPES = new Set(['duckdb', 'oracle']); @@ -157,6 +164,7 @@ export type DataSourceCapabilities = { supportsDropDatabase: boolean; supportsMessagePublish: boolean; forceReadOnlyQueryResult: boolean; + forceReadOnlyStructureDesigner: boolean; preferManualTotalCount: boolean; supportsApproximateTableCount: boolean; supportsApproximateTotalPages: boolean; @@ -209,17 +217,20 @@ const DROP_DATABASE_TYPES = new Set([ export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => { const type = resolveDataSourceType(config); - const forcedReadOnly = isConnectionForcedReadOnly(config); + const dataEditRestricted = isConnectionDataEditRestricted(config); + const structureEditRestricted = isConnectionStructureEditRestricted(config); return { type, supportsQueryEditor: !QUERY_EDITOR_DISABLED_TYPES.has(type), supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type), supportsCopyInsert: COPY_INSERT_TYPES.has(type), - supportsCreateDatabase: !forcedReadOnly && CREATE_DATABASE_TYPES.has(type), - supportsRenameDatabase: !forcedReadOnly && RENAME_DATABASE_TYPES.has(type), - supportsDropDatabase: !forcedReadOnly && DROP_DATABASE_TYPES.has(type), - supportsMessagePublish: !forcedReadOnly && MESSAGE_PUBLISH_TYPES.has(type), - forceReadOnlyQueryResult: forcedReadOnly || FORCE_READ_ONLY_QUERY_TYPES.has(type), + supportsCreateDatabase: !structureEditRestricted && CREATE_DATABASE_TYPES.has(type), + supportsRenameDatabase: !structureEditRestricted && RENAME_DATABASE_TYPES.has(type), + supportsDropDatabase: !structureEditRestricted && DROP_DATABASE_TYPES.has(type), + supportsMessagePublish: !dataEditRestricted && MESSAGE_PUBLISH_TYPES.has(type), + forceReadOnlyQueryResult: dataEditRestricted || FORCE_READ_ONLY_QUERY_TYPES.has(type), + forceReadOnlyStructureDesigner: + structureEditRestricted || FORCE_READ_ONLY_STRUCTURE_DESIGNER_TYPES.has(type), preferManualTotalCount: MANUAL_TOTAL_COUNT_TYPES.has(type), supportsApproximateTableCount: APPROXIMATE_TABLE_COUNT_TYPES.has(type), supportsApproximateTotalPages: APPROXIMATE_TOTAL_PAGE_TYPES.has(type), diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index ee7dcf3..7d8e18c 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -903,6 +903,24 @@ export namespace connection { this.keyPath = source["keyPath"]; } } + export class ConnectionProtectionConfig { + restrictDataEdit?: boolean; + restrictStructureEdit?: boolean; + restrictScriptExecution?: boolean; + restrictDataImport?: boolean; + + static createFrom(source: any = {}) { + return new ConnectionProtectionConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.restrictDataEdit = source["restrictDataEdit"]; + this.restrictStructureEdit = source["restrictStructureEdit"]; + this.restrictScriptExecution = source["restrictScriptExecution"]; + this.restrictDataImport = source["restrictDataImport"]; + } + } export class ConnectionConfig { id?: string; type: string; @@ -913,6 +931,7 @@ export namespace connection { savePassword?: boolean; database: string; readOnly?: boolean; + protection?: ConnectionProtectionConfig; useSSL?: boolean; sslMode?: string; sslCAPath?: string; @@ -965,6 +984,7 @@ export namespace connection { this.savePassword = source["savePassword"]; this.database = source["database"]; this.readOnly = source["readOnly"]; + this.protection = this.convertValues(source["protection"], ConnectionProtectionConfig); this.useSSL = source["useSSL"]; this.sslMode = source["sslMode"]; this.sslCAPath = source["sslCAPath"]; @@ -1021,6 +1041,7 @@ export namespace connection { return a; } } + export class GlobalProxyView { enabled: boolean; type: string; diff --git a/internal/app/connection_readonly.go b/internal/app/connection_readonly.go index 89e73dc..d9bd168 100644 --- a/internal/app/connection_readonly.go +++ b/internal/app/connection_readonly.go @@ -9,6 +9,15 @@ import ( "GoNavi-Wails/internal/connection" ) +type connectionProtectionKey string + +const ( + connectionProtectionDataEdit connectionProtectionKey = "restrictDataEdit" + connectionProtectionStructureEdit connectionProtectionKey = "restrictStructureEdit" + connectionProtectionScriptExecution connectionProtectionKey = "restrictScriptExecution" + connectionProtectionDataImport connectionProtectionKey = "restrictDataImport" +) + var connectionReadOnlySupportedTypes = map[string]struct{}{ "clickhouse": {}, "dameng": {}, @@ -93,8 +102,57 @@ func supportsConnectionReadOnlyMode(config connection.ConnectionConfig) bool { return ok } +func hasAnyConnectionProtection(config connection.ConnectionProtectionConfig) bool { + return config.RestrictDataEdit || + config.RestrictStructureEdit || + config.RestrictScriptExecution || + config.RestrictDataImport +} + +func resolveConnectionProtectionConfig(config connection.ConnectionConfig) connection.ConnectionProtectionConfig { + if !supportsConnectionReadOnlyMode(config) { + return connection.ConnectionProtectionConfig{} + } + if hasAnyConnectionProtection(config.Protection) { + return config.Protection + } + if config.ReadOnly { + return connection.ConnectionProtectionConfig{ + RestrictDataEdit: true, + RestrictStructureEdit: true, + RestrictScriptExecution: true, + RestrictDataImport: true, + } + } + return connection.ConnectionProtectionConfig{} +} + +func isConnectionProtectionEnabled(config connection.ConnectionConfig, key connectionProtectionKey) bool { + protection := resolveConnectionProtectionConfig(config) + switch key { + case connectionProtectionDataEdit: + return protection.RestrictDataEdit + case connectionProtectionStructureEdit: + return protection.RestrictStructureEdit + case connectionProtectionScriptExecution: + return protection.RestrictScriptExecution + case connectionProtectionDataImport: + return protection.RestrictDataImport + default: + return false + } +} + func isConnectionForcedReadOnly(config connection.ConnectionConfig) bool { - return config.ReadOnly && supportsConnectionReadOnlyMode(config) + protection := resolveConnectionProtectionConfig(config) + return protection.RestrictDataEdit && + protection.RestrictStructureEdit && + protection.RestrictScriptExecution && + protection.RestrictDataImport +} + +func isConnectionScriptExecutionRestricted(config connection.ConnectionConfig) bool { + return isConnectionProtectionEnabled(config, connectionProtectionScriptExecution) } func readOnlyConnectionQueryBlockedMessage() string { @@ -109,8 +167,8 @@ func readOnlyConnectionActionBlockedMessage(action string) string { return fmt.Sprintf("当前连接已启用生产保护,禁止执行%s", label) } -func ensureReadOnlyConnectionAllowsQuery(config connection.ConnectionConfig, query string) error { - if !isConnectionForcedReadOnly(config) { +func ensureConnectionAllowsQuery(config connection.ConnectionConfig, query string) error { + if !isConnectionScriptExecutionRestricted(config) { return nil } for _, statement := range splitSQLStatements(query) { @@ -121,13 +179,25 @@ func ensureReadOnlyConnectionAllowsQuery(config connection.ConnectionConfig, que return nil } -func ensureReadOnlyConnectionAllowsAction(config connection.ConnectionConfig, action string) error { - if !isConnectionForcedReadOnly(config) { +func ensureConnectionAllowsAction(config connection.ConnectionConfig, key connectionProtectionKey, action string) error { + if !isConnectionProtectionEnabled(config, key) { return nil } return errors.New(readOnlyConnectionActionBlockedMessage(action)) } +func ensureConnectionAllowsDataEdit(config connection.ConnectionConfig, action string) error { + return ensureConnectionAllowsAction(config, connectionProtectionDataEdit, action) +} + +func ensureConnectionAllowsStructureEdit(config connection.ConnectionConfig, action string) error { + return ensureConnectionAllowsAction(config, connectionProtectionStructureEdit, action) +} + +func ensureConnectionAllowsDataImport(config connection.ConnectionConfig, action string) error { + return ensureConnectionAllowsAction(config, connectionProtectionDataImport, action) +} + func isReadOnlyMongoCommand(query string) bool { trimmed := strings.TrimSpace(query) if !strings.HasPrefix(trimmed, "{") { diff --git a/internal/app/connection_readonly_test.go b/internal/app/connection_readonly_test.go index 5e65549..55efdbd 100644 --- a/internal/app/connection_readonly_test.go +++ b/internal/app/connection_readonly_test.go @@ -21,25 +21,25 @@ func TestSupportsConnectionReadOnlyMode(t *testing.T) { func TestEnsureReadOnlyConnectionAllowsQuery(t *testing.T) { sqlConfig := connection.ConnectionConfig{Type: "postgres", ReadOnly: true} - if err := ensureReadOnlyConnectionAllowsQuery(sqlConfig, "SELECT * FROM users"); err != nil { + if err := ensureConnectionAllowsQuery(sqlConfig, "SELECT * FROM users"); err != nil { t.Fatalf("read-only postgres connection should allow select: %v", err) } - if err := ensureReadOnlyConnectionAllowsQuery(sqlConfig, "UPDATE users SET name = 'next'"); err == nil { + if err := ensureConnectionAllowsQuery(sqlConfig, "UPDATE users SET name = 'next'"); err == nil { t.Fatal("read-only postgres connection should block update") } mongoConfig := connection.ConnectionConfig{Type: "mongodb", ReadOnly: true} - if err := ensureReadOnlyConnectionAllowsQuery(mongoConfig, `{"find":"users","filter":{"active":true}}`); err != nil { + if err := ensureConnectionAllowsQuery(mongoConfig, `{"find":"users","filter":{"active":true}}`); err != nil { t.Fatalf("read-only mongodb connection should allow find: %v", err) } - if err := ensureReadOnlyConnectionAllowsQuery(mongoConfig, `{"delete":"users","deletes":[{"q":{"active":false},"limit":0}]}`); err == nil { + if err := ensureConnectionAllowsQuery(mongoConfig, `{"delete":"users","deletes":[{"q":{"active":false},"limit":0}]}`); err == nil { t.Fatal("read-only mongodb connection should block delete") } } func TestEnsureReadOnlyConnectionAllowsAction(t *testing.T) { config := connection.ConnectionConfig{Type: "postgres", ReadOnly: true} - err := ensureReadOnlyConnectionAllowsAction(config, "删除数据库") + err := ensureConnectionAllowsStructureEdit(config, "删除数据库") if err == nil { t.Fatal("read-only connection should block mutating actions") } @@ -47,3 +47,27 @@ func TestEnsureReadOnlyConnectionAllowsAction(t *testing.T) { t.Fatalf("blocked action message should include action label, got %q", err.Error()) } } + +func TestEnsureConnectionProtectionSeparatesActionCategories(t *testing.T) { + config := connection.ConnectionConfig{ + Type: "postgres", + Protection: connection.ConnectionProtectionConfig{ + RestrictDataEdit: true, + RestrictDataImport: true, + RestrictStructureEdit: false, + }, + } + + if err := ensureConnectionAllowsQuery(config, "UPDATE users SET name = 'next'"); err != nil { + t.Fatalf("script execution should remain allowed when only data-edit/import restrictions are enabled: %v", err) + } + if err := ensureConnectionAllowsDataEdit(config, "提交结果修改"); err == nil { + t.Fatal("data edit restriction should block result changes") + } + if err := ensureConnectionAllowsDataImport(config, "导入数据"); err == nil { + t.Fatal("data import restriction should block imports") + } + if err := ensureConnectionAllowsStructureEdit(config, "删除数据库"); err != nil { + t.Fatalf("structure edits should remain allowed when structure restriction is disabled: %v", err) + } +} diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index beee092..0862048 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -179,7 +179,7 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string) if dbName == "" { return connection.QueryResult{Success: false, Message: "数据库名称不能为空"} } - if err := ensureReadOnlyConnectionAllowsAction(config, "创建数据库"); err != nil { + if err := ensureConnectionAllowsStructureEdit(config, "创建数据库"); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } @@ -324,7 +324,7 @@ func resolveSchemaDDLTargetDatabase(config connection.ConnectionConfig, dbName s } func (a *App) CreateSchema(config connection.ConnectionConfig, dbName string, schemaName string) connection.QueryResult { - if err := ensureReadOnlyConnectionAllowsAction(config, "创建模式"); err != nil { + if err := ensureConnectionAllowsStructureEdit(config, "创建模式"); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } dbType := resolveDDLDBType(config) @@ -352,7 +352,7 @@ func (a *App) CreateSchema(config connection.ConnectionConfig, dbName string, sc } func (a *App) RenameSchema(config connection.ConnectionConfig, dbName string, oldSchemaName string, newSchemaName string) connection.QueryResult { - if err := ensureReadOnlyConnectionAllowsAction(config, "重命名模式"); err != nil { + if err := ensureConnectionAllowsStructureEdit(config, "重命名模式"); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } dbType := resolveDDLDBType(config) @@ -378,7 +378,7 @@ func (a *App) RenameSchema(config connection.ConnectionConfig, dbName string, ol } func (a *App) DropSchema(config connection.ConnectionConfig, dbName string, schemaName string) connection.QueryResult { - if err := ensureReadOnlyConnectionAllowsAction(config, "删除模式"); err != nil { + if err := ensureConnectionAllowsStructureEdit(config, "删除模式"); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } dbType := resolveDDLDBType(config) @@ -636,7 +636,7 @@ func (a *App) RenameDatabase(config connection.ConnectionConfig, oldName string, if oldName == "" || newName == "" { return connection.QueryResult{Success: false, Message: "数据库名称不能为空"} } - if err := ensureReadOnlyConnectionAllowsAction(config, "重命名数据库"); err != nil { + if err := ensureConnectionAllowsStructureEdit(config, "重命名数据库"); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } if strings.EqualFold(oldName, newName) { @@ -682,7 +682,7 @@ func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) co if dbName == "" { return connection.QueryResult{Success: false, Message: "数据库名称不能为空"} } - if err := ensureReadOnlyConnectionAllowsAction(config, "删除数据库"); err != nil { + if err := ensureConnectionAllowsStructureEdit(config, "删除数据库"); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } @@ -719,7 +719,7 @@ func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, old if oldTableName == "" || newTableName == "" { return connection.QueryResult{Success: false, Message: "表名不能为空"} } - if err := ensureReadOnlyConnectionAllowsAction(config, "重命名表"); err != nil { + if err := ensureConnectionAllowsStructureEdit(config, "重命名表"); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } if strings.EqualFold(oldTableName, newTableName) { @@ -774,7 +774,7 @@ func (a *App) DropTable(config connection.ConnectionConfig, dbName string, table if tableName == "" { return connection.QueryResult{Success: false, Message: "表名不能为空"} } - if err := ensureReadOnlyConnectionAllowsAction(config, "删除表"); err != nil { + if err := ensureConnectionAllowsStructureEdit(config, "删除表"); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } @@ -841,7 +841,7 @@ func (a *App) DBQueryWithCancel(config connection.ConnectionConfig, dbName strin } query = sanitizeSQLForPgLike(resolveDDLDBType(config), query) - if err := ensureReadOnlyConnectionAllowsQuery(config, query); err != nil { + if err := ensureConnectionAllowsQuery(config, query); err != nil { return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} } @@ -959,7 +959,7 @@ func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, qu } query = sanitizeSQLForPgLike(resolveDDLDBType(config), query) - if err := ensureReadOnlyConnectionAllowsQuery(config, query); err != nil { + if err := ensureConnectionAllowsQuery(config, query); err != nil { return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} } @@ -1373,7 +1373,7 @@ func (a *App) DBQueryIsolated(config connection.ConnectionConfig, dbName string, runConfig := normalizeRunConfig(config, dbName) query = sanitizeSQLForPgLike(resolveDDLDBType(config), query) - if err := ensureReadOnlyConnectionAllowsQuery(config, query); err != nil { + if err := ensureConnectionAllowsQuery(config, query); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } @@ -2241,7 +2241,7 @@ func (a *App) DropView(config connection.ConnectionConfig, dbName string, viewNa if viewName == "" { return connection.QueryResult{Success: false, Message: "视图名称不能为空"} } - if err := ensureReadOnlyConnectionAllowsAction(config, "删除视图"); err != nil { + if err := ensureConnectionAllowsStructureEdit(config, "删除视图"); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } @@ -2276,7 +2276,7 @@ func (a *App) DropFunction(config connection.ConnectionConfig, dbName string, ro if routineName == "" { return connection.QueryResult{Success: false, Message: "函数/存储过程名称不能为空"} } - if err := ensureReadOnlyConnectionAllowsAction(config, "删除函数或存储过程"); err != nil { + if err := ensureConnectionAllowsStructureEdit(config, "删除函数或存储过程"); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } if routineType != "FUNCTION" && routineType != "PROCEDURE" { @@ -2322,7 +2322,7 @@ func (a *App) RenameView(config connection.ConnectionConfig, dbName string, oldN if oldName == "" || newName == "" { return connection.QueryResult{Success: false, Message: "视图名称不能为空"} } - if err := ensureReadOnlyConnectionAllowsAction(config, "重命名视图"); err != nil { + if err := ensureConnectionAllowsStructureEdit(config, "重命名视图"); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } if strings.EqualFold(oldName, newName) { diff --git a/internal/app/methods_db_transaction.go b/internal/app/methods_db_transaction.go index aa87885..3f600dc 100644 --- a/internal/app/methods_db_transaction.go +++ b/internal/app/methods_db_transaction.go @@ -28,7 +28,7 @@ func (a *App) DBQueryMultiTransactional(config connection.ConnectionConfig, dbNa } query = sanitizeSQLForPgLike(transactionDBType, query) - if err := ensureReadOnlyConnectionAllowsQuery(config, query); err != nil { + if err := ensureConnectionAllowsQuery(config, query); err != nil { return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID} } if !shouldUseManagedSQLTransaction(transactionDBType, query) { diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index ebe2d08..53dcaac 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -1821,7 +1821,7 @@ func (a *App) PreviewImportFile(filePath string) connection.QueryResult { } func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName string) connection.QueryResult { - if err := ensureReadOnlyConnectionAllowsAction(config, "导入数据"); err != nil { + if err := ensureConnectionAllowsDataImport(config, "导入数据"); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ @@ -2167,7 +2167,7 @@ func formatImportSQLValue(dbType, columnType string, value interface{}) string { // ImportDataWithProgress 执行导入并发送进度事件 func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName, tableName, filePath string) connection.QueryResult { - if err := ensureReadOnlyConnectionAllowsAction(config, "导入数据"); err != nil { + if err := ensureConnectionAllowsDataImport(config, "导入数据"); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } runConfig := normalizeRunConfig(config, dbName) @@ -2217,7 +2217,7 @@ func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName, } func (a *App) ApplyChanges(config connection.ConnectionConfig, dbName, tableName string, changes connection.ChangeSet) connection.QueryResult { - if err := ensureReadOnlyConnectionAllowsAction(config, "提交结果修改"); err != nil { + if err := ensureConnectionAllowsDataEdit(config, "提交结果修改"); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } runConfig := normalizeRunConfig(config, dbName) @@ -2246,7 +2246,7 @@ type ChangePreview struct { } func (a *App) PreviewChanges(config connection.ConnectionConfig, dbName, tableName string, changes connection.ChangeSet) connection.QueryResult { - if err := ensureReadOnlyConnectionAllowsAction(config, "预览结果修改"); err != nil { + if err := ensureConnectionAllowsDataEdit(config, "预览结果修改"); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } runConfig := normalizeRunConfig(config, dbName) @@ -2746,7 +2746,7 @@ func tableDataClearActionLabels(mode tableDataClearMode) (actionLabel string, pr func (a *App) runTableDataClear(config connection.ConnectionConfig, dbName string, tableNames []string, mode tableDataClearMode) connection.QueryResult { actionLabel, progressLabel := tableDataClearActionLabels(mode) - if err := ensureReadOnlyConnectionAllowsAction(config, actionLabel); err != nil { + if err := ensureConnectionAllowsDataEdit(config, actionLabel); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } runConfig := normalizeRunConfig(config, dbName) diff --git a/internal/app/methods_sync.go b/internal/app/methods_sync.go index bce7638..0be990c 100644 --- a/internal/app/methods_sync.go +++ b/internal/app/methods_sync.go @@ -11,6 +11,29 @@ import ( "github.com/wailsapp/wails/v2/pkg/runtime" ) +func ensureDataSyncTargetProtection(config sync.SyncConfig) error { + content := strings.ToLower(strings.TrimSpace(config.Content)) + strategy := strings.ToLower(strings.TrimSpace(config.TargetTableStrategy)) + touchesStructure := content == "schema" || + content == "both" || + config.AutoAddColumns || + config.CreateIndexes || + (strategy != "" && strategy != "existing_only") + touchesData := content == "" || content == "data" || content == "both" + + if touchesStructure { + if err := ensureConnectionAllowsStructureEdit(config.TargetConfig, "同步目标结构"); err != nil { + return err + } + } + if touchesData { + if err := ensureConnectionAllowsDataImport(config.TargetConfig, "数据同步写入"); err != nil { + return err + } + } + return nil +} + func (a *App) resolveDataSyncConfigSecrets(config sync.SyncConfig) (sync.SyncConfig, error) { resolved := config sourceConfig, sourceDatabase, err := a.resolveDataSyncEndpointConfig(config.SourceConfig, config.SourceDatabase) @@ -60,7 +83,7 @@ func (a *App) resolveDataSyncEndpointConfig(raw connection.ConnectionConfig, sel // DataSync executes a data synchronization task func (a *App) DataSync(config sync.SyncConfig) sync.SyncResult { - if err := ensureReadOnlyConnectionAllowsAction(config.TargetConfig, "数据同步写入"); err != nil { + if err := ensureDataSyncTargetProtection(config); err != nil { return sync.SyncResult{ Success: false, Message: err.Error(), diff --git a/internal/connection/types.go b/internal/connection/types.go index e059f7c..ce3796e 100644 --- a/internal/connection/types.go +++ b/internal/connection/types.go @@ -77,53 +77,62 @@ type JVMConfig struct { Diagnostic JVMDiagnosticConfig `json:"diagnostic,omitempty"` } +// ConnectionProtectionConfig 存储生产连接保护的细粒度限制项。 +type ConnectionProtectionConfig struct { + RestrictDataEdit bool `json:"restrictDataEdit,omitempty"` + RestrictStructureEdit bool `json:"restrictStructureEdit,omitempty"` + RestrictScriptExecution bool `json:"restrictScriptExecution,omitempty"` + RestrictDataImport bool `json:"restrictDataImport,omitempty"` +} + // ConnectionConfig 存储数据库连接的完整配置,包括 SSH、代理、SSL 等网络层设置。 type ConnectionConfig struct { - ID string `json:"id,omitempty"` - Type string `json:"type"` - Host string `json:"host"` - Port int `json:"port"` - User string `json:"user"` - Password string `json:"password"` - SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection - Database string `json:"database"` - ReadOnly bool `json:"readOnly,omitempty"` // Production guard: allow query-only operations on supported databases - UseSSL bool `json:"useSSL,omitempty"` // MySQL-like SSL/TLS switch - SSLMode string `json:"sslMode,omitempty"` // preferred | required | skip-verify | disable - SSLCAPath string `json:"sslCAPath,omitempty"` // TLS root CA / server certificate path - SSLCertPath string `json:"sslCertPath,omitempty"` // TLS client certificate path (e.g., Dameng) - SSLKeyPath string `json:"sslKeyPath,omitempty"` // TLS client private key path (e.g., Dameng) - UseSSH bool `json:"useSSH"` - SSH SSHConfig `json:"ssh"` - UseProxy bool `json:"useProxy,omitempty"` - Proxy ProxyConfig `json:"proxy,omitempty"` - UseHTTPTunnel bool `json:"useHttpTunnel,omitempty"` - HTTPTunnel HTTPTunnelConfig `json:"httpTunnel,omitempty"` - Driver string `json:"driver,omitempty"` // For custom connection - DSN string `json:"dsn,omitempty"` // For custom connection - ConnectionParams string `json:"connectionParams,omitempty"` // Extra URI query parameters for built-in drivers - Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30) - KeepAliveEnabled bool `json:"keepAliveEnabled,omitempty"` // Enable background keep-alive ping for long-lived cached connections - KeepAliveIntervalMinutes int `json:"keepAliveIntervalMinutes,omitempty"` // Keep-alive ping interval in minutes (default: 240) - RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15) - RedisSentinelMaster string `json:"redisSentinelMaster,omitempty"` // Redis Sentinel master name - RedisSentinelUser string `json:"redisSentinelUser,omitempty"` // Redis Sentinel auth user - RedisSentinelPassword string `json:"redisSentinelPassword,omitempty"` // Redis Sentinel auth password - URI string `json:"uri,omitempty"` // Connection URI for copy/paste - ClickHouseProtocol string `json:"clickHouseProtocol,omitempty"` // auto | http | native - OceanBaseProtocol string `json:"oceanBaseProtocol,omitempty"` // OceanBase tenant compatibility protocol: mysql | oracle - Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port - Topology string `json:"topology,omitempty"` // single | replica | cluster | sentinel - MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user - MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password - ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name - AuthSource string `json:"authSource,omitempty"` // MongoDB authSource - ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference - MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme - MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism - MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user - MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password - JVM JVMConfig `json:"jvm,omitempty"` // JVM connector config + ID string `json:"id,omitempty"` + Type string `json:"type"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` + SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection + Database string `json:"database"` + ReadOnly bool `json:"readOnly,omitempty"` // Legacy production guard compatibility flag. Prefer Protection for new logic. + Protection ConnectionProtectionConfig `json:"protection,omitempty"` + UseSSL bool `json:"useSSL,omitempty"` // MySQL-like SSL/TLS switch + SSLMode string `json:"sslMode,omitempty"` // preferred | required | skip-verify | disable + SSLCAPath string `json:"sslCAPath,omitempty"` // TLS root CA / server certificate path + SSLCertPath string `json:"sslCertPath,omitempty"` // TLS client certificate path (e.g., Dameng) + SSLKeyPath string `json:"sslKeyPath,omitempty"` // TLS client private key path (e.g., Dameng) + UseSSH bool `json:"useSSH"` + SSH SSHConfig `json:"ssh"` + UseProxy bool `json:"useProxy,omitempty"` + Proxy ProxyConfig `json:"proxy,omitempty"` + UseHTTPTunnel bool `json:"useHttpTunnel,omitempty"` + HTTPTunnel HTTPTunnelConfig `json:"httpTunnel,omitempty"` + Driver string `json:"driver,omitempty"` // For custom connection + DSN string `json:"dsn,omitempty"` // For custom connection + ConnectionParams string `json:"connectionParams,omitempty"` // Extra URI query parameters for built-in drivers + Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30) + KeepAliveEnabled bool `json:"keepAliveEnabled,omitempty"` // Enable background keep-alive ping for long-lived cached connections + KeepAliveIntervalMinutes int `json:"keepAliveIntervalMinutes,omitempty"` // Keep-alive ping interval in minutes (default: 240) + RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15) + RedisSentinelMaster string `json:"redisSentinelMaster,omitempty"` // Redis Sentinel master name + RedisSentinelUser string `json:"redisSentinelUser,omitempty"` // Redis Sentinel auth user + RedisSentinelPassword string `json:"redisSentinelPassword,omitempty"` // Redis Sentinel auth password + URI string `json:"uri,omitempty"` // Connection URI for copy/paste + ClickHouseProtocol string `json:"clickHouseProtocol,omitempty"` // auto | http | native + OceanBaseProtocol string `json:"oceanBaseProtocol,omitempty"` // OceanBase tenant compatibility protocol: mysql | oracle + Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port + Topology string `json:"topology,omitempty"` // single | replica | cluster | sentinel + MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user + MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password + ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name + AuthSource string `json:"authSource,omitempty"` // MongoDB authSource + ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference + MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme + MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism + MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user + MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password + JVM JVMConfig `json:"jvm,omitempty"` // JVM connector config } // ResultSetData 表示一个查询结果集(行 + 列名),用于多结果集场景。 diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 99ea9c4..6da1145 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -751,8 +751,22 @@ "connection_modal.field.defaultDatabase.placeholder": "Zum Beispiel: appdb", "connection_modal.field.serviceName.placeholder": "Zum Beispiel: ORCLPDB1", "connection_modal.field.readOnly.label": "Produktionsschutz", - "connection_modal.field.readOnly.help": "Wenn aktiviert, sind für diese Verbindung nur Abfragen erlaubt. Import, Schemaänderungen, Schreibvorgänge und die Nutzung als Synchronisationsziel werden blockiert.", - "connection_modal.field.readOnly.checkbox": "Diese Verbindung als Produktionsverbindung markieren und nur Abfragen erlauben", + "connection_modal.field.readOnly.help": "Select only the restrictions you need for result editing, structure changes, script execution, and import or sync flows.", + "connection_modal.field.readOnly.status.enabledCount": "{{count}} restrictions enabled", + "connection_modal.field.readOnly.status.disabled": "No restrictions", + "connection_modal.field.readOnly.compatibility": "Selecting all options matches the legacy query-only production guard.", + "connection_modal.field.readOnly.option.dataEdit.label": "Restrict data edits", + "connection_modal.field.readOnly.option.dataEdit.help": "Block result-grid edits, bulk clear actions, and message publishing writes on this connection.", + "connection_modal.field.readOnly.option.structureEdit.label": "Restrict structure edits", + "connection_modal.field.readOnly.option.structureEdit.help": "Block create, rename, and drop object actions, and open the table designer in read-only mode.", + "connection_modal.field.readOnly.option.scriptExecution.label": "Restrict script execution", + "connection_modal.field.readOnly.option.scriptExecution.help": "Block mutating SQL statements and MongoDB commands from the query editor.", + "connection_modal.field.readOnly.option.dataImport.label": "Restrict data import", + "connection_modal.field.readOnly.option.dataImport.help": "Block file import, bulk load, and using this connection as a sync target.", + "connection_modal.field.readOnly.summary.title": "Current policy", + "connection_modal.field.readOnly.summary.selected": "{{count}} restrictions are enabled. Unchecked abilities still behave like a normal connection.", + "connection_modal.field.readOnly.summary.empty": "When no restriction is selected, this connection behaves like a normal connection.", + "connection_modal.field.readOnly.tip": "Recommended for production, standby, and governed databases. These restrictions only affect GoNavi behavior for the current connection and do not modify server-side permissions.", "connection_modal.field.clickHouseProtocol.auto": "Automatisch", "connection_modal.field.oceanBaseProtocol.label": "OceanBase-Protokoll", "connection_modal.field.oceanBaseProtocol.help.primary": "Wählen Sie für MySQL-Mandanten MySQL und für Oracle-Mandanten Oracle. GoNavi wählt anhand des Ports automatisch: Für den OB MySQL wire-Port wird die OBClient-Capability-Injektion verwendet (derselbe Pfad wie in Navicat), für den OBProxy Oracle listener-Port Standard-TNS.", @@ -1517,6 +1531,8 @@ "connection_modal.config_section.customDsn.description": "Benutzerdefinierter DSN konfigurieren.", "connection_modal.config_section.jvmRuntime.title": "JVM-Laufzeit", "connection_modal.config_section.jvmRuntime.description": "JVM-Laufzeit konfigurieren.", + "connection_modal.section.readOnly.title": "Produktionsschutz", + "connection_modal.section.readOnly.description": "Choose the high-risk production restrictions you want instead of forcing a single read-only switch.", "connection_modal.field.password": "Passwort", "sidebar.menu.refresh": "Aktualisieren", "sidebar.search.scope.object": "Objekt", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 00604c6..866b5ba 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -751,8 +751,22 @@ "connection_modal.field.defaultDatabase.placeholder": "For example: appdb", "connection_modal.field.serviceName.placeholder": "For example: ORCLPDB1", "connection_modal.field.readOnly.label": "Production guard", - "connection_modal.field.readOnly.help": "When enabled, this connection only allows queries. Import, schema changes, data writes, and sync target operations are blocked.", - "connection_modal.field.readOnly.checkbox": "Mark this as a production connection and allow queries only", + "connection_modal.field.readOnly.help": "Select only the restrictions you need for result editing, structure changes, script execution, and import or sync flows.", + "connection_modal.field.readOnly.status.enabledCount": "{{count}} restrictions enabled", + "connection_modal.field.readOnly.status.disabled": "No restrictions", + "connection_modal.field.readOnly.compatibility": "Selecting all options matches the legacy query-only production guard.", + "connection_modal.field.readOnly.option.dataEdit.label": "Restrict data edits", + "connection_modal.field.readOnly.option.dataEdit.help": "Block result-grid edits, bulk clear actions, and message publishing writes on this connection.", + "connection_modal.field.readOnly.option.structureEdit.label": "Restrict structure edits", + "connection_modal.field.readOnly.option.structureEdit.help": "Block create, rename, and drop object actions, and open the table designer in read-only mode.", + "connection_modal.field.readOnly.option.scriptExecution.label": "Restrict script execution", + "connection_modal.field.readOnly.option.scriptExecution.help": "Block mutating SQL statements and MongoDB commands from the query editor.", + "connection_modal.field.readOnly.option.dataImport.label": "Restrict data import", + "connection_modal.field.readOnly.option.dataImport.help": "Block file import, bulk load, and using this connection as a sync target.", + "connection_modal.field.readOnly.summary.title": "Current policy", + "connection_modal.field.readOnly.summary.selected": "{{count}} restrictions are enabled. Unchecked abilities still behave like a normal connection.", + "connection_modal.field.readOnly.summary.empty": "When no restriction is selected, this connection behaves like a normal connection.", + "connection_modal.field.readOnly.tip": "Recommended for production, standby, and governed databases. These restrictions only affect GoNavi behavior for the current connection and do not modify server-side permissions.", "connection_modal.field.clickHouseProtocol.auto": "Auto", "connection_modal.field.oceanBaseProtocol.label": "OceanBase protocol", "connection_modal.field.oceanBaseProtocol.help.primary": "Choose MySQL for MySQL tenants and Oracle for Oracle tenants. GoNavi selects automatically by port: OB MySQL wire ports use OBClient capability injection (the same path as Navicat), while OBProxy Oracle listener ports use standard TNS.", @@ -1525,6 +1539,8 @@ "connection_modal.config_section.customDsn.description": "Configure a driver-specific DSN.", "connection_modal.config_section.jvmRuntime.title": "JVM runtime", "connection_modal.config_section.jvmRuntime.description": "Configure JVM access modes and diagnostics.", + "connection_modal.section.readOnly.title": "Production guard", + "connection_modal.section.readOnly.description": "Choose the high-risk production restrictions you want instead of forcing a single read-only switch.", "connection_modal.field.password": "Password", "sidebar.menu.refresh": "Refresh", "sidebar.search.scope.object": "Object", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 5cd2be0..2b6d5e8 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -751,8 +751,22 @@ "connection_modal.field.defaultDatabase.placeholder": "例: appdb", "connection_modal.field.serviceName.placeholder": "例: ORCLPDB1", "connection_modal.field.readOnly.label": "本番接続ガード", - "connection_modal.field.readOnly.help": "有効にすると、この接続では問い合わせのみ許可されます。インポート、スキーマ変更、データ書き込み、同期先としての利用は拒否されます。", - "connection_modal.field.readOnly.checkbox": "この接続を本番接続として扱い、問い合わせのみ許可する", + "connection_modal.field.readOnly.help": "Select only the restrictions you need for result editing, structure changes, script execution, and import or sync flows.", + "connection_modal.field.readOnly.status.enabledCount": "{{count}} restrictions enabled", + "connection_modal.field.readOnly.status.disabled": "No restrictions", + "connection_modal.field.readOnly.compatibility": "Selecting all options matches the legacy query-only production guard.", + "connection_modal.field.readOnly.option.dataEdit.label": "Restrict data edits", + "connection_modal.field.readOnly.option.dataEdit.help": "Block result-grid edits, bulk clear actions, and message publishing writes on this connection.", + "connection_modal.field.readOnly.option.structureEdit.label": "Restrict structure edits", + "connection_modal.field.readOnly.option.structureEdit.help": "Block create, rename, and drop object actions, and open the table designer in read-only mode.", + "connection_modal.field.readOnly.option.scriptExecution.label": "Restrict script execution", + "connection_modal.field.readOnly.option.scriptExecution.help": "Block mutating SQL statements and MongoDB commands from the query editor.", + "connection_modal.field.readOnly.option.dataImport.label": "Restrict data import", + "connection_modal.field.readOnly.option.dataImport.help": "Block file import, bulk load, and using this connection as a sync target.", + "connection_modal.field.readOnly.summary.title": "Current policy", + "connection_modal.field.readOnly.summary.selected": "{{count}} restrictions are enabled. Unchecked abilities still behave like a normal connection.", + "connection_modal.field.readOnly.summary.empty": "When no restriction is selected, this connection behaves like a normal connection.", + "connection_modal.field.readOnly.tip": "Recommended for production, standby, and governed databases. These restrictions only affect GoNavi behavior for the current connection and do not modify server-side permissions.", "connection_modal.field.clickHouseProtocol.auto": "自動", "connection_modal.field.oceanBaseProtocol.label": "OceanBase プロトコル", "connection_modal.field.oceanBaseProtocol.help.primary": "MySQL テナントには MySQL、Oracle テナントには Oracle を選択します。GoNavi はポートに応じて自動選択します。OB MySQL wire ポートでは OBClient capability injection(Navicat と同じ経路)を使い、OBProxy Oracle listener ポートでは標準 TNS を使います。", @@ -1517,6 +1531,8 @@ "connection_modal.config_section.customDsn.description": "カスタム DSNを設定します。", "connection_modal.config_section.jvmRuntime.title": "JVM ランタイム", "connection_modal.config_section.jvmRuntime.description": "JVM ランタイムを設定します。", + "connection_modal.section.readOnly.title": "本番接続ガード", + "connection_modal.section.readOnly.description": "Choose the high-risk production restrictions you want instead of forcing a single read-only switch.", "connection_modal.field.password": "パスワード", "sidebar.menu.refresh": "更新", "sidebar.search.scope.object": "オブジェクト", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index 3ae0f60..16fc9f8 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -751,8 +751,22 @@ "connection_modal.field.defaultDatabase.placeholder": "Например: appdb", "connection_modal.field.serviceName.placeholder": "Например: ORCLPDB1", "connection_modal.field.readOnly.label": "Защита прод-подключения", - "connection_modal.field.readOnly.help": "Если включено, для этого подключения разрешены только запросы. Импорт, изменения схемы, запись данных и использование как цели синхронизации будут запрещены.", - "connection_modal.field.readOnly.checkbox": "Пометить это подключение как production и разрешить только запросы", + "connection_modal.field.readOnly.help": "Select only the restrictions you need for result editing, structure changes, script execution, and import or sync flows.", + "connection_modal.field.readOnly.status.enabledCount": "{{count}} restrictions enabled", + "connection_modal.field.readOnly.status.disabled": "No restrictions", + "connection_modal.field.readOnly.compatibility": "Selecting all options matches the legacy query-only production guard.", + "connection_modal.field.readOnly.option.dataEdit.label": "Restrict data edits", + "connection_modal.field.readOnly.option.dataEdit.help": "Block result-grid edits, bulk clear actions, and message publishing writes on this connection.", + "connection_modal.field.readOnly.option.structureEdit.label": "Restrict structure edits", + "connection_modal.field.readOnly.option.structureEdit.help": "Block create, rename, and drop object actions, and open the table designer in read-only mode.", + "connection_modal.field.readOnly.option.scriptExecution.label": "Restrict script execution", + "connection_modal.field.readOnly.option.scriptExecution.help": "Block mutating SQL statements and MongoDB commands from the query editor.", + "connection_modal.field.readOnly.option.dataImport.label": "Restrict data import", + "connection_modal.field.readOnly.option.dataImport.help": "Block file import, bulk load, and using this connection as a sync target.", + "connection_modal.field.readOnly.summary.title": "Current policy", + "connection_modal.field.readOnly.summary.selected": "{{count}} restrictions are enabled. Unchecked abilities still behave like a normal connection.", + "connection_modal.field.readOnly.summary.empty": "When no restriction is selected, this connection behaves like a normal connection.", + "connection_modal.field.readOnly.tip": "Recommended for production, standby, and governed databases. These restrictions only affect GoNavi behavior for the current connection and do not modify server-side permissions.", "connection_modal.field.clickHouseProtocol.auto": "Авто", "connection_modal.field.oceanBaseProtocol.label": "Протокол OceanBase", "connection_modal.field.oceanBaseProtocol.help.primary": "Для арендаторов MySQL выберите MySQL, для арендаторов Oracle выберите Oracle. GoNavi автоматически выбирает режим по порту: для порта OB MySQL wire используется внедрение возможностей OBClient (тот же путь, что и в Navicat), для порта OBProxy Oracle listener используется стандартный TNS.", @@ -1517,6 +1531,8 @@ "connection_modal.config_section.customDsn.description": "Настройте Пользовательский DSN.", "connection_modal.config_section.jvmRuntime.title": "Среда JVM", "connection_modal.config_section.jvmRuntime.description": "Настройте Среда JVM.", + "connection_modal.section.readOnly.title": "Защита прод-подключения", + "connection_modal.section.readOnly.description": "Choose the high-risk production restrictions you want instead of forcing a single read-only switch.", "connection_modal.field.password": "Пароль", "sidebar.menu.refresh": "Обновить", "sidebar.search.scope.object": "Объект", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index a443369..d95281f 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -751,8 +751,22 @@ "connection_modal.field.defaultDatabase.placeholder": "例如:appdb", "connection_modal.field.serviceName.placeholder": "例如:ORCLPDB1", "connection_modal.field.readOnly.label": "生产连接保护", - "connection_modal.field.readOnly.help": "启用后当前连接仅允许查询,禁止导入、结构变更、数据写入和作为同步目标。", - "connection_modal.field.readOnly.checkbox": "标记为生产连接,只允许查询", + "connection_modal.field.readOnly.help": "按需勾选限制项,分别限制结果编辑、结构变更、脚本执行与导入/同步。", + "connection_modal.field.readOnly.status.enabledCount": "已限制 {{count}} 项", + "connection_modal.field.readOnly.status.disabled": "未限制", + "connection_modal.field.readOnly.compatibility": "全部勾选时,行为等价于旧版“只允许查询”的生产保护。", + "connection_modal.field.readOnly.option.dataEdit.label": "限制数据编辑", + "connection_modal.field.readOnly.option.dataEdit.help": "禁止结果集直接修改、批量清空以及消息发布等写入操作。", + "connection_modal.field.readOnly.option.structureEdit.label": "限制结构编辑", + "connection_modal.field.readOnly.option.structureEdit.help": "禁止新建、重命名、删除数据库对象,并以只读方式打开表设计器。", + "connection_modal.field.readOnly.option.scriptExecution.label": "限制脚本执行", + "connection_modal.field.readOnly.option.scriptExecution.help": "禁止在 SQL 编辑器执行会产生写入的 SQL 或 MongoDB 命令。", + "connection_modal.field.readOnly.option.dataImport.label": "限制数据导入", + "connection_modal.field.readOnly.option.dataImport.help": "禁止导入文件、批量装载以及将当前连接作为同步目标。", + "connection_modal.field.readOnly.summary.title": "当前策略", + "connection_modal.field.readOnly.summary.selected": "已启用 {{count}} 项限制,未勾选的能力仍按普通连接处理。", + "connection_modal.field.readOnly.summary.empty": "未选择任何限制项时,此连接按普通连接处理。", + "connection_modal.field.readOnly.tip": "推荐用于生产库、备库和受监管环境。限制只影响当前连接在 GoNavi 内的行为,不会修改数据库服务端权限。", "connection_modal.field.clickHouseProtocol.auto": "自动", "connection_modal.field.oceanBaseProtocol.label": "OceanBase 协议", "connection_modal.field.oceanBaseProtocol.help.primary": "MySQL 租户请选择 MySQL;Oracle 租户请选择 Oracle。GoNavi 会根据端口自动选择:OB MySQL wire 端口走 OBClient capability 注入(与 Navicat 相同路径),OBProxy Oracle listener 端口走标准 TNS。", @@ -1525,6 +1539,8 @@ "connection_modal.config_section.customDsn.description": "配置驱动专用 DSN。", "connection_modal.config_section.jvmRuntime.title": "JVM 运行时", "connection_modal.config_section.jvmRuntime.description": "配置 JVM 访问模式和诊断能力。", + "connection_modal.section.readOnly.title": "生产连接保护", + "connection_modal.section.readOnly.description": "按需勾选生产连接的高风险限制项,而不是一刀切只读。", "connection_modal.field.password": "密码", "sidebar.menu.refresh": "刷新", "sidebar.search.scope.object": "对象", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index e77b2d1..26f87bd 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -751,8 +751,22 @@ "connection_modal.field.defaultDatabase.placeholder": "例如:appdb", "connection_modal.field.serviceName.placeholder": "例如:ORCLPDB1", "connection_modal.field.readOnly.label": "正式連線保護", - "connection_modal.field.readOnly.help": "啟用後目前連線僅允許查詢,禁止匯入、結構變更、資料寫入與作為同步目標。", - "connection_modal.field.readOnly.checkbox": "標記為正式連線,只允許查詢", + "connection_modal.field.readOnly.help": "依需求勾選限制項,分別限制結果編輯、結構變更、腳本執行與匯入/同步。", + "connection_modal.field.readOnly.status.enabledCount": "已限制 {{count}} 項", + "connection_modal.field.readOnly.status.disabled": "未限制", + "connection_modal.field.readOnly.compatibility": "全部勾選時,行為等同舊版「只允許查詢」的正式連線保護。", + "connection_modal.field.readOnly.option.dataEdit.label": "限制資料編輯", + "connection_modal.field.readOnly.option.dataEdit.help": "禁止結果集直接修改、批次清空以及訊息發佈等寫入操作。", + "connection_modal.field.readOnly.option.structureEdit.label": "限制結構編輯", + "connection_modal.field.readOnly.option.structureEdit.help": "禁止新增、重新命名、刪除資料庫物件,並以唯讀方式開啟表設計器。", + "connection_modal.field.readOnly.option.scriptExecution.label": "限制腳本執行", + "connection_modal.field.readOnly.option.scriptExecution.help": "禁止在 SQL 編輯器執行會產生寫入的 SQL 或 MongoDB 指令。", + "connection_modal.field.readOnly.option.dataImport.label": "限制資料匯入", + "connection_modal.field.readOnly.option.dataImport.help": "禁止匯入檔案、批次載入,以及將目前連線作為同步目標。", + "connection_modal.field.readOnly.summary.title": "目前策略", + "connection_modal.field.readOnly.summary.selected": "已啟用 {{count}} 項限制,未勾選的能力仍依一般連線處理。", + "connection_modal.field.readOnly.summary.empty": "未選擇任何限制項時,此連線會依一般連線處理。", + "connection_modal.field.readOnly.tip": "建議用於正式庫、備庫與受管制環境。限制只影響目前連線在 GoNavi 內的行為,不會修改資料庫伺服器權限。", "connection_modal.field.clickHouseProtocol.auto": "自動", "connection_modal.field.oceanBaseProtocol.label": "OceanBase 協議", "connection_modal.field.oceanBaseProtocol.help.primary": "MySQL 租戶請選擇 MySQL;Oracle 租戶請選擇 Oracle。GoNavi 會依連接埠自動選擇:OB MySQL wire 連接埠走 OBClient capability 注入(與 Navicat 相同路徑),OBProxy Oracle listener 連接埠走標準 TNS。", @@ -1517,6 +1531,8 @@ "connection_modal.config_section.customDsn.description": "設定自訂 DSN。", "connection_modal.config_section.jvmRuntime.title": "JVM 執行階段", "connection_modal.config_section.jvmRuntime.description": "設定JVM 執行階段。", + "connection_modal.section.readOnly.title": "正式連線保護", + "connection_modal.section.readOnly.description": "依需求勾選正式連線的高風險限制項,而不是一刀切唯讀。", "connection_modal.field.password": "密碼", "sidebar.menu.refresh": "重新整理", "sidebar.search.scope.object": "物件", From 07b3b908f96d92860980324134f3daf100f24452 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 23 Jun 2026 19:48:43 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20fix(mongodb):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=BC=96=E8=BE=91=E6=80=81=E5=AD=97=E7=AC=A6=E4=B8=B2?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 MongoDB 编辑态补充字段名驱动的保守类型推断 - 统一 DataGrid 基准数据为类型化值,覆盖 JSON、行编辑和单元格编辑 - 保持 pMid 等普通字符串字段不被误判为 ObjectId - 补充 Mongo helper、DataViewer 主键定位与 DataGrid 提交回归测试 --- frontend/src/components/DataGrid.tsx | 50 ++++---- .../src/components/useDataGridModalEditors.ts | 4 +- .../src/components/useDataGridPreviewPanel.ts | 6 +- frontend/src/utils/mongodb.test.ts | 31 +++++ frontend/src/utils/mongodb.ts | 117 ++++++++++++++++-- 5 files changed, 169 insertions(+), 39 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 4d0b373..594b285 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -159,7 +159,7 @@ import { useDataGridColumnResize } from './useDataGridColumnResize'; import { useDataGridPreviewPanel } from './useDataGridPreviewPanel'; import { buildTableExportTab } from '../utils/tableExportTab'; import { buildDataGridCssText } from './dataGridStyles'; -import { formatMongoEditableValue, parseMongoEditedValue } from '../utils/mongodb'; +import { formatMongoEditableValue, normalizeMongoDocumentForEditing, parseMongoEditedValue } from '../utils/mongodb'; // --- Error Boundary --- import { @@ -549,12 +549,12 @@ const DataGrid: React.FC = ({ const filteredExportSql = useMemo(() => String(exportSqlWithFilter || '').trim(), [exportSqlWithFilter]); const hasFilteredExportSql = exportScope === 'table' && filteredExportSql.length > 0; - const mongoAwareEditableText = useCallback((value: any): string => ( - isMongoDBConnection ? formatMongoEditableValue(value) : toEditableText(value) + const mongoAwareEditableText = useCallback((value: any, columnName?: string): string => ( + isMongoDBConnection ? formatMongoEditableValue(value, columnName) : toEditableText(value) ), [isMongoDBConnection]); - const mongoAwareFormText = useCallback((value: any): string => ( - isMongoDBConnection ? formatMongoEditableValue(value) : toFormText(value) + const mongoAwareFormText = useCallback((value: any, columnName?: string): string => ( + isMongoDBConnection ? formatMongoEditableValue(value, columnName) : toFormText(value) ), [isMongoDBConnection]); const normalizeMongoEditedCellValue = useCallback((columnName: string, value: any, currentValue?: any) => ( @@ -1493,9 +1493,15 @@ const DataGrid: React.FC = ({ updateCellSelection, }); + const baseData = useMemo(() => ( + isMongoDBConnection + ? data.map((row) => normalizeMongoDocumentForEditing(row)) + : data + ), [data, isMongoDBConnection]); + const displayData = useMemo(() => { - return [...data, ...addedRows]; - }, [data, addedRows]); + return [...baseData, ...addedRows]; + }, [baseData, addedRows]); useEffect(() => { displayDataRef.current = displayData; }, [displayData]); @@ -1609,7 +1615,7 @@ const DataGrid: React.FC = ({ } if (deletedRowKeys.has(keyStr)) return; // 查找原始行数据,对比是否真正有值变更 - const originalRow = data.find(r => r?.[GONAVI_ROW_KEY] === rowKey); + const originalRow = baseData.find(r => r?.[GONAVI_ROW_KEY] === rowKey); if (originalRow) { const currentRow = modifiedRows[keyStr] ? { ...originalRow, ...modifiedRows[keyStr] } : originalRow; const normalizedRow = normalizeMongoEditedRow(row, currentRow); @@ -1650,7 +1656,7 @@ const DataGrid: React.FC = ({ }); setModifiedRows(prev => ({ ...prev, [keyStr]: normalizedRow })); } - }, [addedRows, data, rowKeyStr, deletedRowKeys, effectiveEditLocator, modifiedRows, normalizeMongoEditedRow]); + }, [addedRows, baseData, rowKeyStr, deletedRowKeys, effectiveEditLocator, modifiedRows, normalizeMongoEditedRow]); const handleDataPanelSave = useCallback(() => { if (!focusedCellInfo) return; @@ -1709,7 +1715,7 @@ const DataGrid: React.FC = ({ return; } - const originalRow = data.find((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === keyStr); + const originalRow = baseData.find((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === keyStr); if (!originalRow) { void message.error(translateDataGrid('data_grid.message.undo_cell_original_missing')); setCellContextMenu(prev => ({ ...prev, visible: false })); @@ -1719,7 +1725,7 @@ const DataGrid: React.FC = ({ handleCellSave({ ...record, [dataIndex]: originalRow[dataIndex] }); setCellContextMenu(prev => ({ ...prev, visible: false })); void message.success(translateDataGrid('data_grid.message.undo_cell_success')); - }, [addedRowKeySet, cellContextMenu.dataIndex, cellContextMenu.record, data, handleCellSave, modifiedColumns, rowKeyStr, translateDataGrid]); + }, [addedRowKeySet, baseData, cellContextMenu.dataIndex, cellContextMenu.record, handleCellSave, modifiedColumns, rowKeyStr, translateDataGrid]); const handleCellEditorSave = useCallback(() => { if (!cellEditorMeta) return; @@ -1769,7 +1775,7 @@ const DataGrid: React.FC = ({ setCellFieldValue(form, fieldName, parseToDayjs(raw, pickerType)); } else { const initialValue = isMongoDBConnection - ? mongoAwareEditableText(raw) + ? mongoAwareEditableText(raw, dataIndex) : (typeof raw === 'string' ? normalizeDateTimeString(raw) : raw); setCellFieldValue(form, fieldName, initialValue); } @@ -2042,7 +2048,7 @@ const DataGrid: React.FC = ({ } const baseRow = - data.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) || + baseData.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) || addedRows.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) || displayRow; @@ -2055,7 +2061,7 @@ const DataGrid: React.FC = ({ const baseVal = (baseRow as any)?.[col]; const displayVal = (displayRow as any)?.[col]; baseRawMap[col] = baseVal; - displayMap[col] = mongoAwareFormText(displayVal); + displayMap[col] = mongoAwareFormText(displayVal, col); // 日期时间类型: 将字符串值转为 dayjs 对象供 DatePicker 使用 const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; const rowPickerType = getTemporalPickerType(colMeta?.type, dbType, currentConnConfig); @@ -2063,7 +2069,7 @@ const DataGrid: React.FC = ({ const dVal = parseToDayjs(displayVal, rowPickerType); formMap[col] = dVal; } else { - formMap[col] = displayVal === null || displayVal === undefined ? undefined : mongoAwareFormText(displayVal); + formMap[col] = displayVal === null || displayVal === undefined ? undefined : mongoAwareFormText(displayVal, col); } if (baseVal === null || baseVal === undefined) nullCols.add(col); }); @@ -2075,7 +2081,7 @@ const DataGrid: React.FC = ({ nullCols, formValues: formMap, }); - }, [addedRows, canModifyData, columnMetaMap, columnMetaMapByLowerName, currentConnConfig, data, dbType, mergedDisplayData, mongoAwareFormText, openRowEditor, rowKeyStr, translateDataGrid, visibleColumnNames]); + }, [addedRows, baseData, canModifyData, columnMetaMap, columnMetaMapByLowerName, currentConnConfig, dbType, mergedDisplayData, mongoAwareFormText, openRowEditor, rowKeyStr, translateDataGrid, visibleColumnNames]); const openCurrentViewRowEditor = useCallback(() => { if (!canModifyData) return; @@ -2139,7 +2145,7 @@ const DataGrid: React.FC = ({ }); const originalMap = new Map(); - data.forEach((r) => { + baseData.forEach((r) => { const key = r?.[GONAVI_ROW_KEY]; if (key === undefined) return; originalMap.set(rowKeyStr(key), r); @@ -2212,7 +2218,7 @@ const DataGrid: React.FC = ({ closeJsonEditor(); void message.success(translateDataGrid('data_grid.message.json_applied')); - }, [canModifyData, jsonEditorValue, mergedDisplayData, addedRows, rowKeyStr, data, visibleColumnNames, effectiveEditLocator, closeJsonEditor, translateDataGrid]); + }, [canModifyData, jsonEditorValue, mergedDisplayData, addedRows, rowKeyStr, baseData, visibleColumnNames, effectiveEditLocator, closeJsonEditor, translateDataGrid]); const openRowEditorFieldEditor = useCallback((dataIndex: string) => { if (!dataIndex) return; @@ -2700,7 +2706,7 @@ const DataGrid: React.FC = ({ addedRows, modifiedRows, deletedRowKeys, - data, + data: baseData, editLocator: effectiveEditLocator, visibleColumnNames, rowKeyToString: rowKeyStr, @@ -2747,7 +2753,7 @@ const DataGrid: React.FC = ({ const rawErrorMessage = e?.message || String(e); void message.error(translateDataGrid('data_grid.message.preview_sql_failed_detail', { detail: rawErrorMessage })); } - }, [addedRows, modifiedRows, deletedRowKeys, data, effectiveEditLocator, + }, [addedRows, modifiedRows, deletedRowKeys, baseData, effectiveEditLocator, visibleColumnNames, rowKeyStr, normalizeCommitCellValue, shouldCommitColumn, connectionId, tableName, connections, rowLocatorMessages, translateDataGrid]); @@ -2760,7 +2766,7 @@ const DataGrid: React.FC = ({ addedRows, modifiedRows, deletedRowKeys, - data, + data: baseData, editLocator: effectiveEditLocator, visibleColumnNames, rowKeyToString: rowKeyStr, @@ -2844,7 +2850,7 @@ const DataGrid: React.FC = ({ addedRows, modifiedRows, deletedRowKeys, - data, + baseData, effectiveEditLocator, visibleColumnNames, rowKeyStr, diff --git a/frontend/src/components/useDataGridModalEditors.ts b/frontend/src/components/useDataGridModalEditors.ts index 375cfa7..73d4696 100644 --- a/frontend/src/components/useDataGridModalEditors.ts +++ b/frontend/src/components/useDataGridModalEditors.ts @@ -18,7 +18,7 @@ interface OpenRowEditorParams { } interface UseDataGridModalEditorsParams { - toEditableText: (value: any) => string; + toEditableText: (value: any, columnName?: string) => string; looksLikeJsonText: (text: string) => boolean; } @@ -100,7 +100,7 @@ export const useDataGridModalEditors = ({ ) => { if (!record || !dataIndex) return; const raw = record?.[dataIndex]; - const text = toEditableText(raw); + const text = toEditableText(raw, dataIndex); const isJson = looksLikeJsonText(text); const titleText = typeof title === 'string' ? title diff --git a/frontend/src/components/useDataGridPreviewPanel.ts b/frontend/src/components/useDataGridPreviewPanel.ts index 59ae07e..0210408 100644 --- a/frontend/src/components/useDataGridPreviewPanel.ts +++ b/frontend/src/components/useDataGridPreviewPanel.ts @@ -9,7 +9,7 @@ export interface DataGridFocusedCellInfo { } interface UseDataGridPreviewPanelParams { - toEditableText: (value: any) => string; + toEditableText: (value: any, columnName?: string) => string; looksLikeJsonText: (text: string) => boolean; normalizeDateTimeString: (value: string) => string; } @@ -44,8 +44,8 @@ export const useDataGridPreviewPanel = ({ const updateFocusedCell = React.useCallback((record: GridRecord, dataIndex: string) => { if (!record || !dataIndex) return; const raw = record?.[dataIndex]; - let text = toEditableText(raw); - if (typeof raw === 'string') { + let text = toEditableText(raw, dataIndex); + if (typeof raw === 'string' && text === raw) { text = normalizeDateTimeString(raw); } const isJson = looksLikeJsonText(text); diff --git a/frontend/src/utils/mongodb.test.ts b/frontend/src/utils/mongodb.test.ts index 25d9a3d..6fefa43 100644 --- a/frontend/src/utils/mongodb.test.ts +++ b/frontend/src/utils/mongodb.test.ts @@ -5,6 +5,7 @@ import { buildMongoFindCommand, convertMongoShellToJsonCommand, formatMongoEditableValue, + normalizeMongoDocumentForEditing, parseMongoEditedValue, } from './mongodb'; @@ -157,6 +158,12 @@ describe('Mongo edit value helpers', () => { })).toBe('UUID("12345678-1234-4678-9234-567812345678")'); }); + it('infers editable Mongo typed literals from common string field names', () => { + expect(formatMongoEditableValue('5a7fb5b93560e06a6e1e4950', 'merchantId')).toBe('ObjectId("5a7fb5b93560e06a6e1e4950")'); + expect(formatMongoEditableValue('5ba279393560e029bb0b6359', 'pMid')).toBe('5ba279393560e029bb0b6359'); + expect(formatMongoEditableValue('2018-06-24 07:42:51.8', 'updateTime')).toBe('ISODate("2018-06-24T07:42:51.800Z")'); + }); + it('parses typed Mongo edit text back to extended JSON wrappers', () => { expect(parseMongoEditedValue('_id', '507f1f77bcf86cd799439011')).toEqual({ $oid: '507f1f77bcf86cd799439011' }); expect(parseMongoEditedValue('createdAt', '2024-06-23T00:00:00.000Z', { $date: { $numberLong: '1719100800000' } })).toEqual({ @@ -173,4 +180,28 @@ describe('Mongo edit value helpers', () => { }, }); }); + + it('infers typed Mongo values from string edits when the field name is sufficient', () => { + expect(parseMongoEditedValue('merchantId', '5a7fb5b93560e06a6e1e4950')).toEqual({ $oid: '5a7fb5b93560e06a6e1e4950' }); + expect(parseMongoEditedValue('updateTime', '2018-06-24 07:42:51.8')).toEqual({ + $date: '2018-06-24T07:42:51.800Z', + }); + expect(parseMongoEditedValue('pMid', '5ba279393560e029bb0b6359')).toBe('5ba279393560e029bb0b6359'); + }); + + it('normalizes Mongo documents for JSON editing without promoting plain string ids blindly', () => { + expect(normalizeMongoDocumentForEditing({ + _id: '5a8262f93560e05dd3465288', + merchantId: '5a7fb5b93560e06a6e1e4950', + pMid: '5ba279393560e029bb0b6359', + updateTime: '2018-06-24 07:42:51.8', + userId: '5a65611fadfce63b96bb2001', + })).toEqual({ + _id: { $oid: '5a8262f93560e05dd3465288' }, + merchantId: { $oid: '5a7fb5b93560e06a6e1e4950' }, + pMid: '5ba279393560e029bb0b6359', + updateTime: { $date: '2018-06-24T07:42:51.800Z' }, + userId: { $oid: '5a65611fadfce63b96bb2001' }, + }); + }); }); diff --git a/frontend/src/utils/mongodb.ts b/frontend/src/utils/mongodb.ts index db30bf6..eef6290 100644 --- a/frontend/src/utils/mongodb.ts +++ b/frontend/src/utils/mongodb.ts @@ -85,6 +85,37 @@ const buildMongoBinaryUUID = (uuidText: string): { $binary: { base64: string; su }, }); +const isMongoObjectIdFieldName = (fieldName: string): boolean => { + const text = String(fieldName || '').trim(); + if (!text) return false; + return text === '_id' + || text === 'id' + || text.endsWith('Id') + || text.endsWith('ID') + || text.endsWith('_id') + || text.endsWith('-id'); +}; + +const isMongoDateFieldName = (fieldName: string): boolean => { + const text = String(fieldName || '').trim(); + if (!text) return false; + return text === 'date' + || text === 'time' + || text === 'timestamp' + || text.endsWith('Date') + || text.endsWith('Time') + || text.endsWith('At') + || text.endsWith('Timestamp') + || text.endsWith('_date') + || text.endsWith('_time') + || text.endsWith('_at') + || text.endsWith('_timestamp') + || text.endsWith('-date') + || text.endsWith('-time') + || text.endsWith('-at') + || text.endsWith('-timestamp'); +}; + const buildMongoDateLiteralText = (raw?: unknown): string => { const millis = typeof raw === 'object' && raw && !Array.isArray(raw) ? parseMongoDateToMillis((raw as Record)?.$numberLong ?? raw) @@ -201,6 +232,17 @@ const parseMongoDateToMillis = (raw: unknown): number | null => { if (Number.isFinite(n)) return Math.trunc(n); } + const naiveMatch = text.match( + /^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2}:\d{2})(\.\d{1,9})?)?$/ + ); + if (naiveMatch) { + const [, datePart, timePart = '00:00:00', fractionPart = ''] = naiveMatch; + const fractionDigits = fractionPart ? `${fractionPart.slice(1)}000`.slice(0, 3) : '000'; + const utcText = `${datePart}T${timePart}.${fractionDigits}Z`; + const utcMillis = Date.parse(utcText); + if (!Number.isNaN(utcMillis)) return utcMillis; + } + const direct = new Date(text); if (!Number.isNaN(direct.getTime())) return direct.getTime(); @@ -379,6 +421,56 @@ const parseMongoJSONValue = (raw: string): unknown => { } }; +const relaxMongoDateValue = (raw: unknown): { $date: string } => ({ + $date: buildMongoDateLiteralText(raw), +}); + +const normalizeMongoFieldValueForEditing = (fieldName: string, value: unknown): unknown => { + if (value === null || typeof value === 'undefined') return value; + + const singleEntry = getSingleMongoOperatorEntry(value); + if (singleEntry) { + if (singleEntry[0] === '$date') { + return relaxMongoDateValue(singleEntry[1]); + } + return value; + } + + if (Array.isArray(value)) { + return value.map((item) => normalizeMongoFieldValueForEditing(fieldName, item)); + } + + if (isPlainMongoObject(value)) { + const next: Record = {}; + Object.entries(value).forEach(([key, nestedValue]) => { + next[key] = normalizeMongoFieldValueForEditing(key, nestedValue); + }); + return next; + } + + if (typeof value !== 'string') return value; + + const text = value.trim(); + if (!text) return value; + + if (isMongoObjectIdFieldName(fieldName) && HEX24_RE.test(text)) { + return { $oid: text.toLowerCase() }; + } + + if (isMongoDateFieldName(fieldName)) { + const millis = parseMongoDateToMillis(text); + if (millis !== null) { + return relaxMongoDateValue(millis); + } + } + + return value; +}; + +export const normalizeMongoDocumentForEditing = (value: T): T => ( + normalizeMongoFieldValueForEditing('', value) as T +); + export const formatMongoValueForDisplay = (value: unknown): string => { if (value === null) return 'NULL'; if (typeof value === 'undefined') return ''; @@ -420,20 +512,21 @@ export const formatMongoValueForDisplay = (value: unknown): string => { return String(value); }; -export const formatMongoEditableValue = (value: unknown): string => { - if (value === null || typeof value === 'undefined') return ''; - const singleEntry = getSingleMongoOperatorEntry(value); +export const formatMongoEditableValue = (value: unknown, columnName = ''): string => { + const normalizedValue = normalizeMongoFieldValueForEditing(columnName, value); + if (normalizedValue === null || typeof normalizedValue === 'undefined') return ''; + const singleEntry = getSingleMongoOperatorEntry(normalizedValue); if (singleEntry) { - return formatMongoValueForDisplay(value); + return formatMongoValueForDisplay(normalizedValue); } - if (Array.isArray(value) || isPlainMongoObject(value)) { + if (Array.isArray(normalizedValue) || isPlainMongoObject(normalizedValue)) { try { - return JSON.stringify(value, null, 2); + return JSON.stringify(normalizedValue, null, 2); } catch { - return String(value); + return String(normalizedValue); } } - return String(value); + return String(normalizedValue); }; export const parseMongoEditedValue = ( @@ -443,7 +536,9 @@ export const parseMongoEditedValue = ( ): unknown => { if (typeof rawValue !== 'string') return rawValue; - const currentKind = resolveMongoValueKind(currentValue); + const normalizedCurrentValue = normalizeMongoFieldValueForEditing(columnName, currentValue); + const inferredRawValue = normalizeMongoFieldValueForEditing(columnName, rawValue); + const currentKind = resolveMongoValueKind(normalizedCurrentValue); const text = rawValue.trim(); const structuredLiteral = looksLikeMongoStructuredLiteral(rawValue); const explicitLiteral = looksLikeExplicitMongoTypedLiteral(rawValue); @@ -501,9 +596,7 @@ export const parseMongoEditedValue = ( case 'string': case 'nullish': default: - if (String(columnName || '').trim() === '_id' && HEX24_RE.test(text)) { - return { $oid: text.toLowerCase() }; - } + if (inferredRawValue !== rawValue) return inferredRawValue; return rawValue; } }; From a2c1b4a7d8df38da1c1f68f05d9c1f1ff8570522 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 23 Jun 2026 20:18:06 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20fix(query-editor):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=96=E9=83=A8SQL=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 放宽活跃 QueryEditor 在文档级快捷键目标下的保存触发条件 - 修复桌面端 Ctrl/Cmd+S 事件落到 document 时未真正写盘的问题 - 保持普通查询保存行为不变,并补充外部 SQL 文件快捷保存回归测试 --- .../QueryEditor.external-sql-save.test.tsx | 41 +++++++++++++++++++ frontend/src/components/QueryEditor.tsx | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 1c143c3..c068483 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -4329,6 +4329,47 @@ describe('QueryEditor external SQL save', () => { expect(messageApi.success).toHaveBeenCalledWith('查询已保存!'); }); + it('allows Ctrl/Cmd+S to save external SQL files from document-level targets', async () => { + const windowListeners: Record void)[]> = {}; + vi.stubGlobal('window', { + addEventListener: vi.fn((type: string, listener: (event?: any) => void) => { + windowListeners[type] ||= []; + windowListeners[type].push(listener); + }), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }); + + const filePath = '/Users/me/Documents/gonavi-queries/report.sql'; + editorState.hasTextFocus = false; + + await act(async () => { + create(); + }); + + editorState.value = 'select 6;'; + const isMacRuntime = /(Mac|iPhone|iPad|iPod)/i.test(`${navigator.platform || ''} ${navigator.userAgent || ''}`); + const event = { + ctrlKey: !isMacRuntime, + metaKey: isMacRuntime, + altKey: false, + shiftKey: false, + key: 's', + target: document.body, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + + await act(async () => { + windowListeners.keydown?.forEach((listener) => listener(event)); + }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 6;'); + expect(messageApi.success).toHaveBeenCalledWith(expect.stringContaining('SQL 文件已保存')); + }); + it('does not create saved queries when external SQL file writes fail', async () => { let renderer!: ReactTestRenderer; const filePath = '/Users/me/Documents/gonavi-queries/report.sql'; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index c619f77..5ef7a17 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -4132,7 +4132,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const targetNode = resolveEventTargetNode(event.target); const editorHasFocus = !!editor?.hasTextFocus?.(); const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); - if (!editorHasFocus && !inQueryEditor) { + if (!editorHasFocus && !inQueryEditor && !isDocumentLevelShortcutTarget(targetNode)) { return; }