From 8d8366c190993c3c84812c646933a61cccff0968 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 9 May 2026 11:11:40 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(query-editor):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20Oracle=20=E6=98=9F=E5=8F=B7=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=E5=88=97=E5=88=AB=E5=90=8D=E9=9D=9E=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Oracle `SELECT *` 改写时使用合法源表别名 `gonavi_query_source` - 让自动注入的 `ROWID` 绑定到源表别名,避免 `ORA-00911` - 保留显式字段查询的 `ROWID` 追加逻辑 - 新增回归测试覆盖 `SELECT * FROM EDC_LOG` 的执行 SQL - 校验生成 SQL 不再包含非法自动别名 --- frontend/package.json.md5 | 2 +- .../QueryEditor.external-sql-save.test.tsx | 49 +++++++- frontend/src/components/QueryEditor.tsx | 119 +++++++++++++++++- frontend/wailsjs/go/models.ts | 20 ++- internal/ai/provider/anthropic.go | 30 ++--- internal/ai/types.go | 15 ++- internal/db/driver_agent_revisions_gen.go | 30 ++--- 7 files changed, 214 insertions(+), 51 deletions(-) diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 7396e24..bed8925 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -d0464f9da25e9356e61652e638c99ffe \ No newline at end of file +0295a42fd931778d85157816d79d29e5 \ No newline at end of file diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index ab95acd..c50362a 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -216,7 +216,7 @@ describe('QueryEditor external SQL save', () => { }); it('writes external SQL file tabs back to disk without creating saved queries', async () => { - let renderer: ReactTestRenderer; + let renderer!: ReactTestRenderer; const filePath = '/Users/me/Documents/gonavi-queries/report.sql'; await act(async () => { @@ -240,7 +240,7 @@ describe('QueryEditor external SQL save', () => { }); it('does not create saved queries when external SQL file writes fail', async () => { - let renderer: ReactTestRenderer; + let renderer!: ReactTestRenderer; const filePath = '/Users/me/Documents/gonavi-queries/report.sql'; backendApp.WriteSQLFile.mockResolvedValueOnce({ success: false, message: '磁盘只读' }); @@ -272,7 +272,7 @@ describe('QueryEditor external SQL save', () => { }, ]; - let renderer: ReactTestRenderer; + let renderer!: ReactTestRenderer; await act(async () => { renderer = create(); }); @@ -412,6 +412,49 @@ describe('QueryEditor external SQL save', () => { expect(messageApi.warning).not.toHaveBeenCalled(); }); + it('rewrites Oracle SELECT * queries before injecting hidden ROWID locator columns', async () => { + storeState.connections[0].config.type = 'oracle'; + storeState.connections[0].config.database = 'ORCLPDB1'; + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ columns: ['WAFER_ID', ORACLE_ROWID_LOCATOR_COLUMN], rows: [{ WAFER_ID: 'R015Z10F08', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }] }], + }); + backendApp.DBGetColumns.mockResolvedValueOnce({ + success: true, + data: [{ name: 'WAFER_ID', key: '' }], + }); + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + await findButton(renderer!, '运行').props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const executedSql = String(backendApp.DBQueryMulti.mock.calls[0][2]); + expect(executedSql).toContain('FROM MYCIMLED.EDC_LOG'); + expect(executedSql).toContain('FROM MYCIMLED.EDC_LOG gonavi_query_source'); + expect(executedSql).not.toContain('__gonavi_query_source__'); + expect(executedSql).not.toContain('SELECT *, ROWID AS'); + expect(executedSql).toMatch(/SELECT\s+gonavi_query_source\.\*\s*,\s+gonavi_query_source\.ROWID\s+AS\s+"__gonavi_oracle_rowid__"/i); + expect(dataGridState.latestProps?.editLocator).toMatchObject({ + strategy: 'oracle-rowid', + columns: ['ROWID'], + valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN], + hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN], + readOnly: false, + }); + expect(dataGridState.latestProps?.readOnly).toBe(false); + expect(messageApi.warning).not.toHaveBeenCalled(); + renderer?.unmount(); + }); + it('keeps non-Oracle query results read-only when no safe locator exists', async () => { backendApp.DBQueryMulti.mockResolvedValueOnce({ success: true, diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index cdceadb..1bdccc6 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -203,6 +203,7 @@ const buildQueryReadOnlyLocator = (reason: string): EditRowLocator => ({ type SimpleSelectInfo = { selectsAll: boolean; + selectsBareAll: boolean; writableColumns: Record; }; @@ -282,6 +283,7 @@ const splitTopLevelComma = (text: string): string[] => { const SIMPLE_IDENTIFIER_PATH_RE = /^(?:[`"\[]?[A-Za-z_][\w$]*[`"\]]?\s*\.\s*){0,2}[`"\[]?[A-Za-z_][\w$]*[`"\]]?$/; const QUERY_ALIAS_RESERVED = new Set([ 'where', 'group', 'order', 'having', 'limit', 'fetch', 'offset', 'join', 'left', 'right', 'inner', 'outer', 'on', 'union', + 'for', 'connect', 'start', 'window', 'sample', 'pivot', 'unpivot', 'qualify', 'model', ]); const getLastIdentifierPart = (path: string): string => { @@ -325,16 +327,21 @@ const parseSimpleSelectInfo = (sql: string): SimpleSelectInfo | undefined => { const writableColumns: Record = {}; let selectsAll = false; + let selectsBareAll = false; for (const item of splitTopLevelComma(selectList)) { + const trimmedItem = String(item || '').trim(); const resolved = resolveSimpleSelectItemColumn(item); if (!resolved) continue; if (resolved === 'all') { selectsAll = true; + if (trimmedItem === '*') { + selectsBareAll = true; + } continue; } writableColumns[resolved.resultName] = resolved.sourceName; } - return { selectsAll, writableColumns }; + return { selectsAll, selectsBareAll, writableColumns }; }; const appendQuerySelectExpressions = (sql: string, expressions: string[]): string => { @@ -345,6 +352,89 @@ const appendQuerySelectExpressions = (sql: string, expressions: string[]): strin ); }; +const QUERY_LOCATOR_SOURCE_ALIAS = 'gonavi_query_source'; + +const rewriteOracleSelectAllWithExpressions = (sql: string, expressions: string[]): string | undefined => { + if (expressions.length === 0) return undefined; + + const match = String(sql || '').match(/^(\s*SELECT\s+)([\s\S]+?)(\s+FROM\s+)([\s\S]*)$/i); + if (!match) return undefined; + + const prefix = match[1]; + const selectList = match[2].trim(); + const fromKeyword = match[3]; + const fromTail = match[4]; + const selectItems = splitTopLevelComma(selectList); + if (selectItems.length === 0) return undefined; + + let selectAllFound = false; + for (const item of selectItems) { + if (String(item || '').trim() === '*') { + selectAllFound = true; + break; + } + } + if (!selectAllFound) return undefined; + + const fromTrimmed = fromTail.trimStart(); + const tableMatch = fromTrimmed.match(/^((?:[`"\[]?\w+[`"\]]?)(?:\s*\.\s*(?:[`"\[]?\w+[`"\]]?)){0,2})([\s\S]*)$/); + if (!tableMatch) return undefined; + + const tableText = tableMatch[1]; + const afterTable = tableMatch[2] || ''; + + const parseAlias = (tail: string): { alias: string; remainder: string } => { + const trimmedTail = String(tail || '').trimStart(); + if (!trimmedTail) { + return { alias: '', remainder: tail }; + } + + const asMatch = trimmedTail.match(/^AS\s+([`"\[]?[A-Za-z_][\w$]*[`"\]]?)([\s\S]*)$/i); + if (asMatch) { + const candidate = stripQueryIdentifierQuotes(asMatch[1]); + if (candidate && !QUERY_ALIAS_RESERVED.has(candidate.toLowerCase())) { + return { alias: candidate, remainder: asMatch[2] || '' }; + } + } + + const bareMatch = trimmedTail.match(/^([`"\[]?[A-Za-z_][\w$]*[`"\]]?)([\s\S]*)$/); + if (bareMatch) { + const candidate = stripQueryIdentifierQuotes(bareMatch[1]); + if (candidate && !QUERY_ALIAS_RESERVED.has(candidate.toLowerCase())) { + return { alias: candidate, remainder: bareMatch[2] || '' }; + } + } + + return { alias: '', remainder: tail }; + }; + + const parsedAlias = parseAlias(afterTable); + const sourceAlias = parsedAlias.alias || QUERY_LOCATOR_SOURCE_ALIAS; + const qualifiedExpressions = expressions + .map((expression) => { + const trimmed = String(expression || '').trim(); + if (!trimmed) return ''; + if (/^ROWID\b/i.test(trimmed)) { + return trimmed.replace(/^(\s*)ROWID\b/i, `$1${sourceAlias}.ROWID`); + } + return trimmed; + }) + .filter(Boolean); + if (qualifiedExpressions.length === 0) return undefined; + + const rewrittenSelectItems = selectItems.map((item) => { + const trimmed = String(item || '').trim(); + if (trimmed === '*') { + return `${sourceAlias}.*`; + } + return item.trimEnd(); + }); + + const aliasClause = parsedAlias.alias ? ` ${parsedAlias.alias}` : ` ${sourceAlias}`; + const finalSelectItems = [...rewrittenSelectItems, ...qualifiedExpressions]; + return `${prefix}${finalSelectItems.join(', ')}${fromKeyword}${tableText}${aliasClause}${parsedAlias.remainder}`; +}; + const findWritableResultColumnForSource = (writableColumns: Record, target: string): string | undefined => { const normalizedTarget = String(target || '').trim().toLowerCase(); return Object.entries(writableColumns || {}).find(([, sourceColumn]) => ( @@ -361,8 +451,8 @@ const buildQueryLocatorColumnExpression = (dbType: string, column: string, alias `${quoteIdentPart(dbType, column)} AS ${quoteIdentPart(dbType, alias)}` ); -const buildQueryRowIDExpression = (dbType: string): string => ( - `ROWID AS ${quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN)}` +const buildQueryRowIDExpression = (dbType: string, sourceAlias?: string): string => ( + `${sourceAlias ? `${sourceAlias}.` : ''}ROWID AS ${quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN)}` ); const resolveQueryLocatorPlan = async ({ @@ -428,6 +518,7 @@ const resolveQueryLocatorPlan = async ({ }); const appendExpressions: string[] = []; const hiddenColumns: string[] = []; + let needsOracleRowIDExpression = false; const buildColumnLocator = (strategy: 'primary-key' | 'unique-key', locatorColumns: string[]): EditRowLocator => { const valueColumns = locatorColumns.map((column, index) => { @@ -457,7 +548,7 @@ const resolveQueryLocatorPlan = async ({ if (uniqueKeyGroup) { plan.editLocator = buildColumnLocator('unique-key', uniqueKeyGroup); } else if (isOracleLikeDialect(dbType)) { - appendExpressions.push(buildQueryRowIDExpression(dbType)); + needsOracleRowIDExpression = true; plan.editLocator = { strategy: 'oracle-rowid', columns: ['ROWID'], @@ -475,7 +566,25 @@ const resolveQueryLocatorPlan = async ({ } } - plan.executedSql = appendQuerySelectExpressions(statement, appendExpressions); + const executableAppendExpressions = [ + ...(needsOracleRowIDExpression ? [buildQueryRowIDExpression(dbType)] : []), + ...appendExpressions, + ]; + + if (executableAppendExpressions.length > 0 && isOracleLikeDialect(dbType) && selectInfo.selectsBareAll) { + const rewritten = rewriteOracleSelectAllWithExpressions(statement, executableAppendExpressions); + if (rewritten) { + plan.executedSql = rewritten; + return plan; + } + + const reason = 'Oracle 查询使用 * 时无法自动注入 ROWID 定位列,已保持只读。'; + plan.editLocator = buildQueryReadOnlyLocator(reason); + plan.warning = `查询结果保持只读:${reason}`; + return plan; + } + + plan.executedSql = appendQuerySelectExpressions(statement, executableAppendExpressions); return plan; } catch { const reason = `无法加载 ${tableRef.metadataDbName}.${tableRef.metadataTableName} 的主键/唯一索引元数据,无法安全提交修改。`; diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 291eebe..e4dd23c 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,10 +1,23 @@ export namespace ai { + export class ToolCallFunction { + name: string; + arguments: string; + + static createFrom(source: any = {}) { + return new ToolCallFunction(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.arguments = source["arguments"]; + } + } export class ToolCall { id: string; type: string; - // Go type: struct { Name string "json:\"name\""; Arguments string "json:\"arguments\"" } - function: any; + function: ToolCallFunction; static createFrom(source: any = {}) { return new ToolCall(source); @@ -14,7 +27,7 @@ export namespace ai { if ('string' === typeof source) source = JSON.parse(source); this.id = source["id"]; this.type = source["type"]; - this.function = this.convertValues(source["function"], Object); + this.function = this.convertValues(source["function"], ToolCallFunction); } convertValues(a: any, classs: any, asMap: boolean = false): any { @@ -178,6 +191,7 @@ export namespace ai { } } + } diff --git a/internal/ai/provider/anthropic.go b/internal/ai/provider/anthropic.go index 434db4c..8f3c03d 100644 --- a/internal/ai/provider/anthropic.go +++ b/internal/ai/provider/anthropic.go @@ -108,13 +108,13 @@ func (p *AnthropicProvider) Validate() error { // --- 请求体类型 --- type anthropicRequest struct { - Model string `json:"model"` - Messages []anthropicMessage `json:"messages"` - System string `json:"system,omitempty"` - MaxTokens int `json:"max_tokens"` - Temperature float64 `json:"temperature,omitempty"` - Stream bool `json:"stream,omitempty"` - Tools []anthropicTool `json:"tools,omitempty"` + Model string `json:"model"` + Messages []anthropicMessage `json:"messages"` + System string `json:"system,omitempty"` + MaxTokens int `json:"max_tokens"` + Temperature float64 `json:"temperature,omitempty"` + Stream bool `json:"stream,omitempty"` + Tools []anthropicTool `json:"tools,omitempty"` } // anthropicTool Anthropic 格式的工具定义 @@ -321,10 +321,7 @@ func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C toolCalls = append(toolCalls, ai.ToolCall{ ID: block.ID, Type: "function", - Function: struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - }{ + Function: ai.ToolCallFunction{ Name: block.Name, Arguments: argsStr, }, @@ -388,9 +385,9 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest, // 跟踪当前活跃的 tool_use blocks type activeToolUse struct { - id string - name string - argsJSON strings.Builder + id string + name string + argsJSON strings.Builder } activeBlocks := make(map[int]*activeToolUse) // index -> block @@ -443,10 +440,7 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest, { ID: block.id, Type: "function", - Function: struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - }{ + Function: ai.ToolCallFunction{ Name: block.name, Arguments: argsStr, }, diff --git a/internal/ai/types.go b/internal/ai/types.go index ef589fa..9a58684 100644 --- a/internal/ai/types.go +++ b/internal/ai/types.go @@ -2,12 +2,15 @@ package ai // ToolCall 表示 AI 发出的工具调用 type ToolCall struct { - ID string `json:"id"` - Type string `json:"type"` // "function" - Function struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` + ID string `json:"id"` + Type string `json:"type"` // "function" + Function ToolCallFunction `json:"function"` +} + +// ToolCallFunction 表示单次工具调用的函数信息 +type ToolCallFunction struct { + Name string `json:"name"` + Arguments string `json:"arguments"` } // ToolFunction 表示可使用的函数定义 diff --git a/internal/db/driver_agent_revisions_gen.go b/internal/db/driver_agent_revisions_gen.go index 12ffe96..8044be9 100644 --- a/internal/db/driver_agent_revisions_gen.go +++ b/internal/db/driver_agent_revisions_gen.go @@ -4,20 +4,20 @@ package db func init() { optionalDriverAgentRevisions = map[string]string{ - "mariadb": "src-ac4e31956af63048", - "oceanbase": "src-f0c94a098a955e89", - "diros": "src-4565a49afb9b942b", - "sphinx": "src-4f9ec83df79bc8f7", - "sqlserver": "src-172613975f6f18d2", - "sqlite": "src-2ff8c7eb368b324b", - "duckdb": "src-8af9c516b81fd5ee", - "dameng": "src-659f5656149e216c", - "kingbase": "src-82ff6ff9440233cd", - "highgo": "src-a3915194d9a50d5d", - "vastbase": "src-20413d5fc104e9fc", - "opengauss": "src-a4d1946fea5c229c", - "mongodb": "src-93d3f3ba9a564a1d", - "tdengine": "src-11ff132d18cb7d9a", - "clickhouse": "src-8e9642cd16e7e147", + "mariadb": "src-1a1cc64f8f92d92b", + "oceanbase": "src-ac051813e2451265", + "diros": "src-bcc78fa43671ade5", + "sphinx": "src-404765c2fda68c5f", + "sqlserver": "src-d9fba1eca0a27c49", + "sqlite": "src-0c26dc1106aace56", + "duckdb": "src-70005eca35bb25c7", + "dameng": "src-b2748e843ec2fcbf", + "kingbase": "src-f826a940f40212f2", + "highgo": "src-b9ef687ba9a056c9", + "vastbase": "src-43e1328091959345", + "opengauss": "src-87f992c30e0035e7", + "mongodb": "src-eaec5eeb4a94f0ed", + "tdengine": "src-bce489d4e3cf967b", + "clickhouse": "src-794edadecce4a328", } }