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",
}
}