🐛 fix(query-editor): 修复 Oracle 星号查询定位列别名非法

- Oracle `SELECT *` 改写时使用合法源表别名 `gonavi_query_source`
- 让自动注入的 `ROWID` 绑定到源表别名,避免 `ORA-00911`
- 保留显式字段查询的 `ROWID` 追加逻辑
- 新增回归测试覆盖 `SELECT * FROM EDC_LOG` 的执行 SQL
- 校验生成 SQL 不再包含非法自动别名
This commit is contained in:
Syngnat
2026-05-09 11:11:40 +08:00
parent faef619413
commit 8d8366c190
7 changed files with 214 additions and 51 deletions

View File

@@ -1 +1 @@
d0464f9da25e9356e61652e638c99ffe
0295a42fd931778d85157816d79d29e5

View File

@@ -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(<QueryEditor tab={createTab({ savedQueryId: 'saved-1' })} />);
});
@@ -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(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT * FROM MYCIMLED.EDC_LOG' })} />);
});
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,

View File

@@ -203,6 +203,7 @@ const buildQueryReadOnlyLocator = (reason: string): EditRowLocator => ({
type SimpleSelectInfo = {
selectsAll: boolean;
selectsBareAll: boolean;
writableColumns: Record<string, string>;
};
@@ -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<string, string> = {};
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<string, string>, 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} 的主键/唯一索引元数据,无法安全提交修改。`;

View File

@@ -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 {
}
}
}

View File

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

View File

@@ -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 表示可使用的函数定义

View File

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