mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-04 21:07:45 +08:00
🐛 fix(duckdb): 修复唯一索引识别与多库对象解析
- 合并 DuckDB 约束与索引元数据,恢复唯一索引表的可编辑判定 - 修复 attach 多库场景下 catalog/schema/table 定位混乱问题 - 统一前后端 qualified name 解析,支持带点和带引号对象名 - 补充 DuckDB 元数据与编辑链路回归测试
This commit is contained in:
126
frontend/src/utils/qualifiedName.ts
Normal file
126
frontend/src/utils/qualifiedName.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
export type QualifiedNameParts = {
|
||||
parentPath: string;
|
||||
objectName: string;
|
||||
};
|
||||
|
||||
const normalizeIdentifierEscapes = (raw: string): string => {
|
||||
let value = String(raw || '').trim();
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const next = String(value || '').trim()
|
||||
.replace(/\\\\"/g, '\\"')
|
||||
.replace(/\\"/g, '"');
|
||||
if (next === value) break;
|
||||
value = next;
|
||||
}
|
||||
return String(value || '').trim();
|
||||
};
|
||||
|
||||
export const stripIdentifierQuotes = (part: string): string => {
|
||||
const text = normalizeIdentifierEscapes(part);
|
||||
if (!text) return '';
|
||||
if (text.length >= 2) {
|
||||
const first = text[0];
|
||||
const last = text[text.length - 1];
|
||||
if (first === '"' && last === '"') {
|
||||
return text.slice(1, -1).replace(/""/g, '"').trim();
|
||||
}
|
||||
if (first === '`' && last === '`') {
|
||||
return text.slice(1, -1).replace(/``/g, '`').trim();
|
||||
}
|
||||
if (first === '[' && last === ']') {
|
||||
return text.slice(1, -1).replace(/]]/g, ']').trim();
|
||||
}
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
export const splitQualifiedNameSegments = (qualifiedName: string): string[] => {
|
||||
const text = normalizeIdentifierEscapes(qualifiedName);
|
||||
if (!text) return [];
|
||||
|
||||
const segments: string[] = [];
|
||||
let current = '';
|
||||
let inDouble = false;
|
||||
let inBacktick = false;
|
||||
let inBracket = false;
|
||||
|
||||
const flush = () => {
|
||||
const value = current.trim();
|
||||
current = '';
|
||||
if (!value) return;
|
||||
segments.push(stripIdentifierQuotes(value));
|
||||
};
|
||||
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
|
||||
if (inDouble) {
|
||||
current += ch;
|
||||
if (ch === '"' && text[i + 1] === '"') {
|
||||
current += text[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') inDouble = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBacktick) {
|
||||
current += ch;
|
||||
if (ch === '`' && text[i + 1] === '`') {
|
||||
current += text[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '`') inBacktick = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBracket) {
|
||||
current += ch;
|
||||
if (ch === ']' && text[i + 1] === ']') {
|
||||
current += text[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === ']') inBracket = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
inDouble = true;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '`') {
|
||||
inBacktick = true;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '[') {
|
||||
inBracket = true;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '.') {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
|
||||
flush();
|
||||
return segments;
|
||||
};
|
||||
|
||||
export const splitQualifiedName = (qualifiedName: string): QualifiedNameParts => {
|
||||
const segments = splitQualifiedNameSegments(qualifiedName);
|
||||
if (segments.length === 0) return { parentPath: '', objectName: '' };
|
||||
if (segments.length === 1) return { parentPath: '', objectName: segments[0] };
|
||||
return {
|
||||
parentPath: segments.slice(0, -1).join('.'),
|
||||
objectName: segments[segments.length - 1],
|
||||
};
|
||||
};
|
||||
|
||||
export const splitQualifiedNameLast = splitQualifiedName;
|
||||
@@ -1,17 +1,5 @@
|
||||
import { normalizeOceanBaseProtocol } from './oceanBaseProtocol';
|
||||
|
||||
const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
|
||||
const raw = String(qualifiedName || '').trim();
|
||||
if (!raw) return { schemaName: '', objectName: '' };
|
||||
const idx = raw.lastIndexOf('.');
|
||||
if (idx <= 0 || idx >= raw.length - 1) {
|
||||
return { schemaName: '', objectName: raw };
|
||||
}
|
||||
return {
|
||||
schemaName: raw.substring(0, idx),
|
||||
objectName: raw.substring(idx + 1),
|
||||
};
|
||||
};
|
||||
import { splitQualifiedNameLast } from './qualifiedName';
|
||||
|
||||
const normalizeSidebarConnectionDialect = (type: string, driver: string, oceanBaseProtocol?: string): string => {
|
||||
const normalizedType = String(type || '').trim().toLowerCase();
|
||||
@@ -45,7 +33,7 @@ export const normalizeSidebarViewName = (dialect: string, dbName: string, schema
|
||||
}
|
||||
|
||||
if (normalizedDialect === 'mysql') {
|
||||
const parsed = splitQualifiedName(normalizedViewName);
|
||||
const parsed = splitQualifiedNameLast(normalizedViewName);
|
||||
if (parsed.objectName) {
|
||||
return parsed.objectName;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildOrderBySQL, buildPaginatedSelectSQL, reverseOrderBySQL } from './sql';
|
||||
import { buildOrderBySQL, buildPaginatedSelectSQL, quoteQualifiedIdent, reverseOrderBySQL } from './sql';
|
||||
|
||||
describe('buildOrderBySQL', () => {
|
||||
it('does not add fallback ORDER BY for DuckDB without explicit sort', () => {
|
||||
@@ -52,3 +52,15 @@ describe('reverseOrderBySQL', () => {
|
||||
.toBe(' ORDER BY COALESCE([a], [b]) DESC, [id] ASC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('quoteQualifiedIdent', () => {
|
||||
it('does not split dots inside quoted DuckDB identifiers', () => {
|
||||
expect(quoteQualifiedIdent('duckdb', '"daily.events"."2026.06"'))
|
||||
.toBe('"daily.events"."2026.06"');
|
||||
});
|
||||
|
||||
it('preserves three-part DuckDB names with quoted dots', () => {
|
||||
expect(quoteQualifiedIdent('duckdb', '"analytics.catalog"."main.schema"."daily.events"'))
|
||||
.toBe('"analytics.catalog"."main.schema"."daily.events"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { splitQualifiedNameSegments, stripIdentifierQuotes } from './qualifiedName';
|
||||
|
||||
export type FilterCondition = {
|
||||
id?: number;
|
||||
enabled?: boolean;
|
||||
@@ -8,17 +10,7 @@ export type FilterCondition = {
|
||||
value2?: string;
|
||||
};
|
||||
|
||||
const normalizeIdentPart = (ident: string) => {
|
||||
let raw = (ident || '').trim();
|
||||
if (!raw) return raw;
|
||||
const first = raw[0];
|
||||
const last = raw[raw.length - 1];
|
||||
if ((first === '"' && last === '"') || (first === '`' && last === '`')) {
|
||||
raw = raw.slice(1, -1).trim();
|
||||
}
|
||||
raw = raw.replace(/["`]/g, '').trim();
|
||||
return raw;
|
||||
};
|
||||
const normalizeIdentPart = (ident: string) => stripIdentifierQuotes(ident);
|
||||
|
||||
// 检查标识符是否需要引号(包含特殊字符或是保留字)
|
||||
const needsQuote = (ident: string): boolean => {
|
||||
@@ -62,9 +54,10 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
|
||||
export const quoteQualifiedIdent = (dbType: string, ident: string) => {
|
||||
const raw = (ident || '').trim();
|
||||
if (!raw) return raw;
|
||||
const parts = raw.split('.').map(normalizeIdentPart).filter(Boolean);
|
||||
if (parts.length <= 1) return quoteIdentPart(dbType, raw);
|
||||
return parts.map(p => quoteIdentPart(dbType, p)).join('.');
|
||||
const parts = splitQualifiedNameSegments(raw).filter(Boolean);
|
||||
if (parts.length === 0) return quoteIdentPart(dbType, raw);
|
||||
if (parts.length === 1 && parts[0] === normalizeIdentPart(raw)) return quoteIdentPart(dbType, raw);
|
||||
return parts.map((part) => quoteIdentPart(dbType, part)).join('.');
|
||||
};
|
||||
|
||||
export const escapeLiteral = (val: string) => (val || '').replace(/'/g, "''");
|
||||
|
||||
Reference in New Issue
Block a user