mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-15 12:27:38 +08:00
🐛 fix(query-editor): 修复 Oracle 星号查询定位列别名非法
- Oracle `SELECT *` 改写时使用合法源表别名 `gonavi_query_source` - 让自动注入的 `ROWID` 绑定到源表别名,避免 `ORA-00911` - 保留显式字段查询的 `ROWID` 追加逻辑 - 新增回归测试覆盖 `SELECT * FROM EDC_LOG` 的执行 SQL - 校验生成 SQL 不再包含非法自动别名
This commit is contained in:
@@ -1 +1 @@
|
||||
d0464f9da25e9356e61652e638c99ffe
|
||||
0295a42fd931778d85157816d79d29e5
|
||||
@@ -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,
|
||||
|
||||
@@ -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} 的主键/唯一索引元数据,无法安全提交修改。`;
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 表示可使用的函数定义
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user